@pie-players/pie-assessment-toolkit 0.3.44 → 0.3.45
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 +54 -69
- package/dist/components/ItemToolBar.custom-element.js +479 -324
- package/dist/components/PieAssessmentToolkit.custom-element.js +19498 -3590
- package/dist/components/SectionToolBar.custom-element.js +761 -507
- package/dist/index.d.ts +3 -6
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/policy/core/ToolPolicyEngine.d.ts +1 -2
- package/dist/policy/core/ToolPolicyEngine.js +1 -2
- package/dist/policy/core/ToolPolicyEngine.js.map +1 -1
- package/dist/policy/core/pnp-policy-inputs.d.ts +2 -3
- package/dist/policy/core/pnp-policy-inputs.js +2 -3
- package/dist/policy/core/pnp-policy-inputs.js.map +1 -1
- package/dist/policy/core/provenance.d.ts +7 -34
- package/dist/policy/core/provenance.js +4 -21
- package/dist/policy/core/provenance.js.map +1 -1
- package/dist/policy/sources/PnpPolicySource.d.ts +6 -19
- package/dist/policy/sources/PnpPolicySource.js +6 -19
- package/dist/policy/sources/PnpPolicySource.js.map +1 -1
- package/dist/runtime/SectionRuntimeEngine.d.ts +13 -28
- package/dist/runtime/SectionRuntimeEngine.js +13 -27
- package/dist/runtime/SectionRuntimeEngine.js.map +1 -1
- package/dist/runtime/adapter/SectionEngineAdapter.d.ts +2 -2
- package/dist/runtime/adapter/SectionEngineAdapter.js +2 -2
- package/dist/runtime/adapter/SectionEngineAdapter.js.map +1 -1
- package/dist/runtime/adapter/coordinator-bridge.d.ts +1 -1
- package/dist/runtime/adapter/coordinator-bridge.js +1 -1
- package/dist/runtime/adapter/coordinator-bridge.js.map +1 -1
- package/dist/runtime/adapter/dom-event-bridge.d.ts +1 -1
- package/dist/runtime/adapter/dom-event-bridge.js +1 -1
- package/dist/runtime/adapter/dom-event-bridge.js.map +1 -1
- package/dist/runtime/catalog-registration.d.ts +12 -0
- package/dist/runtime/catalog-registration.js +54 -0
- package/dist/runtime/catalog-registration.js.map +1 -0
- package/dist/runtime/core/engine-output.d.ts +2 -2
- package/dist/runtime/core/engine-output.js +2 -2
- package/dist/runtime/core/engine-output.js.map +1 -1
- package/dist/runtime/core/engine-readiness.d.ts +2 -2
- package/dist/runtime/core/engine-readiness.js.map +1 -1
- package/dist/runtime/core/engine-resolver.d.ts +13 -41
- package/dist/runtime/core/engine-resolver.js +19 -76
- package/dist/runtime/core/engine-resolver.js.map +1 -1
- package/dist/runtime/core/engine-transition.d.ts +1 -1
- package/dist/runtime/core/engine-transition.js +1 -1
- package/dist/runtime/core/engine-transition.js.map +1 -1
- package/dist/runtime/engine.d.ts +1 -1
- package/dist/runtime/engine.js.map +1 -1
- package/dist/runtime/section-runtime-engine-host-context.d.ts +22 -15
- package/dist/runtime/section-runtime-engine-host-context.js +10 -10
- package/dist/runtime/section-runtime-engine-host-context.js.map +1 -1
- package/dist/runtime/stage-emit-gate.d.ts +4 -4
- package/dist/runtime/stage-emit-gate.js +3 -3
- package/dist/runtime/stage-emit-gate.js.map +1 -1
- package/dist/services/AccessibilityCatalogResolver.d.ts +23 -0
- package/dist/services/AccessibilityCatalogResolver.js +200 -28
- package/dist/services/AccessibilityCatalogResolver.js.map +1 -1
- package/dist/services/HighlightCoordinator.d.ts +25 -0
- package/dist/services/HighlightCoordinator.js +125 -0
- package/dist/services/HighlightCoordinator.js.map +1 -1
- package/dist/services/SSMLExtractor.d.ts +16 -1
- package/dist/services/SSMLExtractor.js +86 -7
- package/dist/services/SSMLExtractor.js.map +1 -1
- package/dist/services/TTSService.d.ts +30 -4
- package/dist/services/TTSService.js +652 -159
- package/dist/services/TTSService.js.map +1 -1
- package/dist/services/ToolRegistry.d.ts +2 -2
- package/dist/services/ToolRegistry.js +1 -1
- package/dist/services/ToolRegistry.js.map +1 -1
- package/dist/services/ToolkitCoordinator.d.ts +16 -73
- package/dist/services/ToolkitCoordinator.js +34 -113
- package/dist/services/ToolkitCoordinator.js.map +1 -1
- package/dist/services/interfaces.d.ts +20 -2
- package/dist/services/interfaces.js.map +1 -1
- package/dist/services/tool-config-validation.d.ts +1 -1
- package/dist/services/tool-config-validation.js +7 -7
- package/dist/services/tool-config-validation.js.map +1 -1
- package/dist/services/tool-instance-id.js +1 -1
- package/dist/services/tool-instance-id.js.map +1 -1
- package/dist/services/tool-providers/TTSToolProvider.js +3 -3
- package/dist/services/tool-providers/TTSToolProvider.js.map +1 -1
- package/dist/services/tools-config-normalizer.d.ts +13 -2
- package/dist/services/tools-config-normalizer.js +3 -8
- package/dist/services/tools-config-normalizer.js.map +1 -1
- package/dist/services/tts/browser-provider.js +2 -0
- package/dist/services/tts/browser-provider.js.map +1 -1
- package/dist/services/tts/catalog-span-alignment.d.ts +39 -0
- package/dist/services/tts/catalog-span-alignment.js +300 -0
- package/dist/services/tts/catalog-span-alignment.js.map +1 -0
- package/dist/services/tts/generated-speech/assemble-plan.d.ts +33 -0
- package/dist/services/tts/generated-speech/assemble-plan.js +111 -0
- package/dist/services/tts/generated-speech/assemble-plan.js.map +1 -0
- package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +25 -0
- package/dist/services/tts/generated-speech/dom/build-dom-plan.js +66 -0
- package/dist/services/tts/generated-speech/dom/build-dom-plan.js.map +1 -0
- package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +19 -0
- package/dist/services/tts/generated-speech/dom/to-chunk-inputs.js +71 -0
- package/dist/services/tts/generated-speech/dom/to-chunk-inputs.js.map +1 -0
- package/dist/services/tts/generated-speech/dom/types.d.ts +37 -0
- package/dist/services/tts/generated-speech/dom/types.js +2 -0
- package/dist/services/tts/generated-speech/dom/types.js.map +1 -0
- package/dist/services/tts/generated-speech/index.d.ts +17 -0
- package/dist/services/tts/generated-speech/index.js +17 -0
- package/dist/services/tts/generated-speech/index.js.map +1 -0
- package/dist/services/tts/generated-speech/math-speech-cache.d.ts +15 -0
- package/dist/services/tts/generated-speech/math-speech-cache.js +78 -0
- package/dist/services/tts/generated-speech/math-speech-cache.js.map +1 -0
- package/dist/services/tts/generated-speech/types.d.ts +104 -0
- package/dist/services/tts/generated-speech/types.js +18 -0
- package/dist/services/tts/generated-speech/types.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/boundary-normalizer.d.ts +2 -0
- package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +106 -0
- package/dist/services/tts/highlight-pipeline/boundary-normalizer.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/create-highlight-plan.d.ts +6 -0
- package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +217 -0
- package/dist/services/tts/highlight-pipeline/create-highlight-plan.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/highlight-policy.d.ts +2 -0
- package/dist/services/tts/highlight-pipeline/highlight-policy.js +32 -0
- package/dist/services/tts/highlight-pipeline/highlight-policy.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/index.d.ts +9 -0
- package/dist/services/tts/highlight-pipeline/index.js +9 -0
- package/dist/services/tts/highlight-pipeline/index.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/math-semantic-resolver.d.ts +3 -0
- package/dist/services/tts/highlight-pipeline/math-semantic-resolver.js +32 -0
- package/dist/services/tts/highlight-pipeline/math-semantic-resolver.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.d.ts +29 -0
- package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +44 -0
- package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.d.ts +2 -0
- package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +20 -0
- package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/readable-region.d.ts +1 -0
- package/dist/services/tts/highlight-pipeline/readable-region.js +19 -0
- package/dist/services/tts/highlight-pipeline/readable-region.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/rendered-math-target-resolver.d.ts +6 -0
- package/dist/services/tts/highlight-pipeline/rendered-math-target-resolver.js +198 -0
- package/dist/services/tts/highlight-pipeline/rendered-math-target-resolver.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/types.d.ts +72 -0
- package/dist/services/tts/highlight-pipeline/types.js +2 -0
- package/dist/services/tts/highlight-pipeline/types.js.map +1 -0
- package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +2 -0
- package/dist/services/tts/highlight-pipeline/visible-map-range.js +22 -0
- package/dist/services/tts/highlight-pipeline/visible-map-range.js.map +1 -0
- package/dist/services/tts/math-alignment/index.d.ts +30 -0
- package/dist/services/tts/math-alignment/index.js +66 -0
- package/dist/services/tts/math-alignment/index.js.map +1 -0
- package/dist/services/tts/math-alignment/mathml-tokenizer.d.ts +2 -0
- package/dist/services/tts/math-alignment/mathml-tokenizer.js +317 -0
- package/dist/services/tts/math-alignment/mathml-tokenizer.js.map +1 -0
- package/dist/services/tts/math-alignment/range-resolver.d.ts +10 -0
- package/dist/services/tts/math-alignment/range-resolver.js +13 -0
- package/dist/services/tts/math-alignment/range-resolver.js.map +1 -0
- package/dist/services/tts/math-alignment/sequence-aligner.d.ts +7 -0
- package/dist/services/tts/math-alignment/sequence-aligner.js +119 -0
- package/dist/services/tts/math-alignment/sequence-aligner.js.map +1 -0
- package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +31 -0
- package/dist/services/tts/math-alignment/speech-tokenizer.js +143 -0
- package/dist/services/tts/math-alignment/speech-tokenizer.js.map +1 -0
- package/dist/services/tts/math-alignment/types.d.ts +58 -0
- package/dist/services/tts/math-alignment/types.js +2 -0
- package/dist/services/tts/math-alignment/types.js.map +1 -0
- package/dist/services/tts/math-aware-text-processing.d.ts +18 -0
- package/dist/services/tts/math-aware-text-processing.js +348 -0
- package/dist/services/tts/math-aware-text-processing.js.map +1 -0
- package/dist/services/tts/math-speech.d.ts +44 -0
- package/dist/services/tts/math-speech.js +147 -0
- package/dist/services/tts/math-speech.js.map +1 -0
- package/dist/services/tts/mathml-sanitization.d.ts +1 -0
- package/dist/services/tts/mathml-sanitization.js +170 -0
- package/dist/services/tts/mathml-sanitization.js.map +1 -0
- package/dist/services/tts/ssml/spoken-text.d.ts +33 -0
- package/dist/services/tts/ssml/spoken-text.js +159 -0
- package/dist/services/tts/ssml/spoken-text.js.map +1 -0
- package/dist/services/tts/text-processing.d.ts +3 -0
- package/dist/services/tts/text-processing.js +30 -4
- package/dist/services/tts/text-processing.js.map +1 -1
- package/dist/services/tts-runtime-config.d.ts +17 -1
- package/dist/services/tts-runtime-config.js +11 -1
- package/dist/services/tts-runtime-config.js.map +1 -1
- package/dist/tools/registrations/accessibility-tools.js +0 -1
- package/dist/tools/registrations/accessibility-tools.js.map +1 -1
- package/dist/tools/registrations/calculator.js +41 -2
- package/dist/tools/registrations/calculator.js.map +1 -1
- package/dist/tools/tool-tag-map.js +0 -1
- package/dist/tools/tool-tag-map.js.map +1 -1
- package/package.json +8 -11
- package/dist/services/PNPToolResolver.d.ts +0 -280
- package/dist/services/PNPToolResolver.js +0 -514
- package/dist/services/PNPToolResolver.js.map +0 -1
- package/dist/services/pnp-provenance.d.ts +0 -166
- package/dist/services/pnp-provenance.js +0 -231
- package/dist/services/pnp-provenance.js.map +0 -1
|
@@ -15,9 +15,16 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Part of PIE Assessment Toolkit.
|
|
17
17
|
*/
|
|
18
|
+
import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
|
|
18
19
|
import { BrowserTTSProvider } from "./tts/browser-provider.js";
|
|
19
|
-
import { collectVisibleTextAndMap,
|
|
20
|
+
import { collectVisibleTextAndMap, isElementHiddenForTTS, isNodeHiddenForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
|
|
21
|
+
import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
|
|
22
|
+
import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
|
|
23
|
+
import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
|
|
24
|
+
import { buildGeneratedSpeechFromRoot, createMemoizedMathSpeechResolver, planToCompositionChunkInputs, } from "./tts/generated-speech/index.js";
|
|
25
|
+
import { normalizeSREMathSpeechOptions, } from "./tts/math-speech.js";
|
|
20
26
|
import { segmentSentences as segmentTextToSentences, } from "./tts/text-segmentation.js";
|
|
27
|
+
import { createTTSHighlightPlan, normalizeSpeechChunks, resolveReadableRegion, } from "./tts/highlight-pipeline/index.js";
|
|
21
28
|
import { PIE_TTS_CONTROL_HANDOFF_EVENT, } from "./tts-control-events.js";
|
|
22
29
|
/**
|
|
23
30
|
* Playback state
|
|
@@ -30,6 +37,30 @@ export var PlaybackState;
|
|
|
30
37
|
PlaybackState["PAUSED"] = "paused";
|
|
31
38
|
PlaybackState["ERROR"] = "error";
|
|
32
39
|
})(PlaybackState || (PlaybackState = {}));
|
|
40
|
+
const sameRange = (left, right) => left === right ||
|
|
41
|
+
(left.startContainer === right.startContainer &&
|
|
42
|
+
left.startOffset === right.startOffset &&
|
|
43
|
+
left.endContainer === right.endContainer &&
|
|
44
|
+
left.endOffset === right.endOffset);
|
|
45
|
+
const sameRenderableHighlightTarget = (left, right) => {
|
|
46
|
+
if (left === right)
|
|
47
|
+
return true;
|
|
48
|
+
if (!left || !right || left.type !== right.type || left.quality !== right.quality) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (left.type === "range" && right.type === "range") {
|
|
52
|
+
return sameRange(left.range, right.range);
|
|
53
|
+
}
|
|
54
|
+
if (left.type === "element" && right.type === "element") {
|
|
55
|
+
return left.element === right.element;
|
|
56
|
+
}
|
|
57
|
+
if (left.type === "text-range" && right.type === "text-range") {
|
|
58
|
+
return (left.node === right.node &&
|
|
59
|
+
left.startOffset === right.startOffset &&
|
|
60
|
+
left.endOffset === right.endOffset);
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
33
64
|
/**
|
|
34
65
|
* TTSService
|
|
35
66
|
*
|
|
@@ -55,8 +86,38 @@ export class TTSService {
|
|
|
55
86
|
currentSeekSegmentIndex = 0;
|
|
56
87
|
activeSentenceStartOffset = null;
|
|
57
88
|
activeHighlightMode = "word";
|
|
89
|
+
lastRenderedRegionTarget = null;
|
|
58
90
|
telemetryReporter = null;
|
|
91
|
+
// Memoized SRE resolver for the runtime generated-speech path (PIE-623).
|
|
92
|
+
// Caches DOM-free spoken text keyed by SRE settings and MathML source across
|
|
93
|
+
// utterances within this service instance.
|
|
94
|
+
generatedMathSpeechResolver = createMemoizedMathSpeechResolver();
|
|
95
|
+
// Verbose alignment/highlight tracing is opt-in: it is expensive (large
|
|
96
|
+
// objects, a full text diff per utterance) and noisy in production. Enable
|
|
97
|
+
// with `PIE_TTS_DEBUG=1` or `globalThis.__PIE_TTS_DEBUG__ = true`.
|
|
98
|
+
static debugEnabled = (() => {
|
|
99
|
+
try {
|
|
100
|
+
if (typeof process !== "undefined" &&
|
|
101
|
+
process.env?.PIE_TTS_DEBUG === "1") {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// `process` is not defined in the browser; fall through to the global.
|
|
107
|
+
}
|
|
108
|
+
return (globalThis.__PIE_TTS_DEBUG__ === true);
|
|
109
|
+
})();
|
|
59
110
|
constructor() { }
|
|
111
|
+
debugLog(message, detail) {
|
|
112
|
+
if (!TTSService.debugEnabled)
|
|
113
|
+
return;
|
|
114
|
+
if (detail === undefined) {
|
|
115
|
+
console.debug(message);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.debug(message, detail);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
60
121
|
async emitTelemetry(eventName, payload) {
|
|
61
122
|
try {
|
|
62
123
|
await this.telemetryReporter?.(eventName, payload);
|
|
@@ -81,8 +142,27 @@ export class TTSService {
|
|
|
81
142
|
typeof providerOptions.__pieTelemetry === "function"
|
|
82
143
|
? providerOptions.__pieTelemetry
|
|
83
144
|
: null;
|
|
84
|
-
// Initialize provider and get implementation
|
|
85
|
-
|
|
145
|
+
// Initialize provider and get implementation. Browser fallback is only
|
|
146
|
+
// allowed here, before the configured provider has successfully become
|
|
147
|
+
// the active runtime. Per-call playback/synthesis errors must surface
|
|
148
|
+
// instead of silently replacing a user-selected provider and voice.
|
|
149
|
+
try {
|
|
150
|
+
this.provider = await provider.initialize(config);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (provider.providerId.toLowerCase() === "browser") {
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (!this.isBrowserSpeechFallbackAvailable()) {
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const switched = await this.switchProviderToBrowser(error, {
|
|
160
|
+
operation: "tts-initialize",
|
|
161
|
+
});
|
|
162
|
+
if (!switched) {
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
86
166
|
}
|
|
87
167
|
/**
|
|
88
168
|
* Update TTS settings dynamically without full reinitialization
|
|
@@ -97,10 +177,21 @@ export class TTSService {
|
|
|
97
177
|
throw new Error("TTSService not initialized. Call initialize() first.");
|
|
98
178
|
}
|
|
99
179
|
// If the provider implementation has an updateSettings method, use it
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
180
|
+
const provider = this.provider;
|
|
181
|
+
if (typeof provider.updateSettings === "function") {
|
|
182
|
+
const mergedSettings = {
|
|
183
|
+
...settings,
|
|
184
|
+
...(settings.providerOptions
|
|
185
|
+
? {
|
|
186
|
+
providerOptions: {
|
|
187
|
+
...(this.ttsConfig.providerOptions || {}),
|
|
188
|
+
...settings.providerOptions,
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
: {}),
|
|
192
|
+
};
|
|
193
|
+
await provider.updateSettings(mergedSettings);
|
|
194
|
+
this.ttsConfig = { ...this.ttsConfig, ...mergedSettings };
|
|
104
195
|
}
|
|
105
196
|
else {
|
|
106
197
|
// Fallback: Reinitialize with merged config
|
|
@@ -138,7 +229,8 @@ export class TTSService {
|
|
|
138
229
|
*/
|
|
139
230
|
getTextProcessingOptions(language) {
|
|
140
231
|
const providerOptions = (this.ttsConfig.providerOptions || {});
|
|
141
|
-
const textNormalization = (providerOptions.textNormalization ||
|
|
232
|
+
const textNormalization = (providerOptions.textNormalization ||
|
|
233
|
+
{});
|
|
142
234
|
const mode = textNormalization.boundarySpacingMode;
|
|
143
235
|
const boundarySpacingMode = mode === "none" || mode === "alnum" || mode === "segmenterPreferred"
|
|
144
236
|
? mode
|
|
@@ -149,11 +241,18 @@ export class TTSService {
|
|
|
149
241
|
: undefined);
|
|
150
242
|
return { locale, boundarySpacingMode };
|
|
151
243
|
}
|
|
244
|
+
getMathSpeechOptions() {
|
|
245
|
+
const providerOptions = (this.ttsConfig.providerOptions || {});
|
|
246
|
+
const mathSpeech = providerOptions.mathSpeech;
|
|
247
|
+
return normalizeSREMathSpeechOptions(mathSpeech);
|
|
248
|
+
}
|
|
152
249
|
buildPositionMap(element, spokenText, language) {
|
|
153
250
|
this.normalizedToDOM.clear();
|
|
154
251
|
const { text: normalizedDomText, map } = collectVisibleTextAndMap(element, this.getTextProcessingOptions(language));
|
|
155
252
|
this.normalizedToDOM = map;
|
|
156
|
-
|
|
253
|
+
if (!TTSService.debugEnabled)
|
|
254
|
+
return;
|
|
255
|
+
this.debugLog("[TTSService] Text comparison:", {
|
|
157
256
|
spokenLength: spokenText.length,
|
|
158
257
|
normalizedDomLength: normalizedDomText.length,
|
|
159
258
|
match: spokenText === normalizedDomText,
|
|
@@ -176,7 +275,7 @@ export class TTSService {
|
|
|
176
275
|
return null;
|
|
177
276
|
})(),
|
|
178
277
|
});
|
|
179
|
-
|
|
278
|
+
this.debugLog("[TTSService] Position map built:", {
|
|
180
279
|
entries: this.normalizedToDOM.size,
|
|
181
280
|
spokenTextLength: spokenText.length,
|
|
182
281
|
normalizedDomLength: normalizedDomText.length,
|
|
@@ -242,9 +341,6 @@ export class TTSService {
|
|
|
242
341
|
isElementHidden(element) {
|
|
243
342
|
return isElementHiddenForTTS(element);
|
|
244
343
|
}
|
|
245
|
-
extractVisibleText(element, language) {
|
|
246
|
-
return extractVisibleTextFromDOM(element, this.getTextProcessingOptions(language));
|
|
247
|
-
}
|
|
248
344
|
getBoundaryStrength(element) {
|
|
249
345
|
const explicitBreakMs = Number(element.getAttribute("data-tts-break-ms"));
|
|
250
346
|
if (Number.isFinite(explicitBreakMs) && explicitBreakMs > 0)
|
|
@@ -289,7 +385,10 @@ export class TTSService {
|
|
|
289
385
|
}
|
|
290
386
|
const role = (current.getAttribute("role") || "").toLowerCase();
|
|
291
387
|
const tagName = current.tagName.toUpperCase();
|
|
292
|
-
if (tagName.match(/^H[1-6]$/) ||
|
|
388
|
+
if (tagName.match(/^H[1-6]$/) ||
|
|
389
|
+
role === "heading" ||
|
|
390
|
+
role === "listitem" ||
|
|
391
|
+
tagName === "LI") {
|
|
293
392
|
best = current;
|
|
294
393
|
break;
|
|
295
394
|
}
|
|
@@ -485,7 +584,9 @@ export class TTSService {
|
|
|
485
584
|
await providerWithPlan.speakSegments(segments);
|
|
486
585
|
}
|
|
487
586
|
finally {
|
|
488
|
-
this.provider
|
|
587
|
+
if (runId === this.speakRunId && this.provider === providerWithPlan) {
|
|
588
|
+
this.provider.onWordBoundary = originalOnWordBoundary;
|
|
589
|
+
}
|
|
489
590
|
}
|
|
490
591
|
return;
|
|
491
592
|
}
|
|
@@ -611,49 +712,64 @@ export class TTSService {
|
|
|
611
712
|
throw new Error("TTS service not initialized");
|
|
612
713
|
}
|
|
613
714
|
const runId = ++this.speakRunId;
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
this.activeHighlightMode = highlightMode;
|
|
624
|
-
const shouldUsePlan = !!this.currentContentElement &&
|
|
625
|
-
!usedCatalogSpoken &&
|
|
626
|
-
!this.hasExplicitBreakSemantics(contentToSpeak);
|
|
627
|
-
this.seekSegments = this.hasExplicitBreakSemantics(contentToSpeak)
|
|
628
|
-
? []
|
|
629
|
-
: shouldUsePlan && this.currentContentElement
|
|
630
|
-
? this.createSpeechPlan(this.currentContentElement, normalizedText)
|
|
631
|
-
: this.createSeekSegmentsFromText(contentToSpeak);
|
|
632
|
-
this.sentenceHighlightSegments = this.hasExplicitBreakSemantics(contentToSpeak)
|
|
633
|
-
? []
|
|
634
|
-
: this.createSentenceHighlightSegments({
|
|
715
|
+
try {
|
|
716
|
+
await this.applyLanguageSettings(options);
|
|
717
|
+
if (runId !== this.speakRunId)
|
|
718
|
+
return;
|
|
719
|
+
const resolvedContent = await this.resolveSpeechContent(text, options);
|
|
720
|
+
if (runId !== this.speakRunId)
|
|
721
|
+
return;
|
|
722
|
+
const { contentToSpeak, speechText, highlightText, normalizedText, usedCatalogSpoken, speechSource, speechMatchesVisibleText, speechChunks, } = resolvedContent;
|
|
723
|
+
this.logResolvedSpeechContent({
|
|
635
724
|
contentToSpeak,
|
|
636
|
-
|
|
637
|
-
|
|
725
|
+
speechSource,
|
|
726
|
+
catalogId: options?.catalogId,
|
|
638
727
|
});
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
shouldUsePlan
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
728
|
+
this.initializeSpeakTracking(highlightText, options);
|
|
729
|
+
const highlightMode = options?.highlightModeOverride ||
|
|
730
|
+
(speechMatchesVisibleText ? this.resolveHighlightMode() : "sentence");
|
|
731
|
+
this.activeHighlightMode = highlightMode;
|
|
732
|
+
const hasExplicitBreaks = this.hasExplicitBreakSemantics(contentToSpeak);
|
|
733
|
+
const shouldUsePlan = !!this.currentContentElement &&
|
|
734
|
+
!usedCatalogSpoken &&
|
|
735
|
+
!hasExplicitBreaks &&
|
|
736
|
+
speechMatchesVisibleText;
|
|
737
|
+
this.seekSegments =
|
|
738
|
+
hasExplicitBreaks || !speechMatchesVisibleText
|
|
739
|
+
? []
|
|
740
|
+
: shouldUsePlan && this.currentContentElement
|
|
741
|
+
? this.createSpeechPlan(this.currentContentElement, normalizedText)
|
|
742
|
+
: this.createSeekSegmentsFromText(highlightText);
|
|
743
|
+
this.sentenceHighlightSegments = hasExplicitBreaks
|
|
744
|
+
? []
|
|
745
|
+
: this.createSentenceHighlightSegments({
|
|
746
|
+
contentToSpeak: highlightText,
|
|
747
|
+
shouldUsePlan,
|
|
748
|
+
playbackSegments: this.seekSegments,
|
|
749
|
+
});
|
|
750
|
+
this.setState(PlaybackState.LOADING);
|
|
751
|
+
this.prepareHighlightsForSpeak({
|
|
752
|
+
contentToSpeak: highlightText,
|
|
753
|
+
options,
|
|
648
754
|
highlightMode,
|
|
649
|
-
|
|
755
|
+
shouldUsePlan,
|
|
650
756
|
});
|
|
757
|
+
if (speechMatchesVisibleText) {
|
|
758
|
+
this.configureWordBoundaryHighlighting({
|
|
759
|
+
highlightMode,
|
|
760
|
+
wordBoundaryOffset: options?.wordBoundaryOffset || 0,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
this.clearWordBoundaryHighlighting();
|
|
765
|
+
}
|
|
651
766
|
this.setState(PlaybackState.PLAYING);
|
|
652
767
|
await this.executeSpeakPlayback({
|
|
653
768
|
shouldUsePlan,
|
|
654
769
|
runId,
|
|
655
770
|
highlightMode,
|
|
656
|
-
contentToSpeak,
|
|
771
|
+
contentToSpeak: speechText,
|
|
772
|
+
speechChunks,
|
|
657
773
|
});
|
|
658
774
|
if (runId !== this.speakRunId)
|
|
659
775
|
return;
|
|
@@ -662,26 +778,6 @@ export class TTSService {
|
|
|
662
778
|
}
|
|
663
779
|
catch (error) {
|
|
664
780
|
let finalError = error;
|
|
665
|
-
if (runId === this.speakRunId) {
|
|
666
|
-
try {
|
|
667
|
-
const recovered = await this.retryWithBrowserFallback({
|
|
668
|
-
error,
|
|
669
|
-
runId,
|
|
670
|
-
shouldUsePlan,
|
|
671
|
-
highlightMode,
|
|
672
|
-
contentToSpeak,
|
|
673
|
-
wordBoundaryOffset: options?.wordBoundaryOffset || 0,
|
|
674
|
-
});
|
|
675
|
-
if (recovered) {
|
|
676
|
-
this.setState(PlaybackState.IDLE);
|
|
677
|
-
this.clearHighlightsAndTracking();
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
catch (retryError) {
|
|
682
|
-
finalError = retryError;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
781
|
console.error("TTS error:", finalError);
|
|
686
782
|
this.lastError =
|
|
687
783
|
finalError instanceof Error ? finalError.message : String(finalError);
|
|
@@ -692,35 +788,26 @@ export class TTSService {
|
|
|
692
788
|
throw finalError;
|
|
693
789
|
}
|
|
694
790
|
}
|
|
695
|
-
|
|
696
|
-
if (!this.provider || !this.currentProvider)
|
|
697
|
-
return false;
|
|
698
|
-
const providerId = (this.currentProvider.providerId || "").toLowerCase();
|
|
699
|
-
if (providerId === "browser")
|
|
700
|
-
return false;
|
|
701
|
-
const message = (error instanceof Error ? error.message : String(error))
|
|
702
|
-
.toLowerCase()
|
|
703
|
-
.trim();
|
|
704
|
-
// Avoid fallback for expected cancellation/superseded flows.
|
|
705
|
-
if (message.includes("superseded by a newer request"))
|
|
706
|
-
return false;
|
|
707
|
-
if (message.includes("aborterror"))
|
|
708
|
-
return false;
|
|
709
|
-
if (message.includes("aborted"))
|
|
710
|
-
return false;
|
|
711
|
-
if (message.includes("cancelled"))
|
|
712
|
-
return false;
|
|
713
|
-
if (message.includes("canceled"))
|
|
714
|
-
return false;
|
|
715
|
-
// For non-browser providers, default to browser fallback on runtime errors.
|
|
716
|
-
return true;
|
|
717
|
-
}
|
|
718
|
-
async switchProviderToBrowser(reason) {
|
|
791
|
+
async switchProviderToBrowser(reason, context) {
|
|
719
792
|
if (!this.currentProvider)
|
|
720
793
|
return false;
|
|
721
794
|
const fallbackProvider = new BrowserTTSProvider();
|
|
722
795
|
const previousProviderId = this.currentProvider.providerId;
|
|
723
796
|
const browserConfig = { ...this.ttsConfig };
|
|
797
|
+
const operation = context?.operation || "tts-initialize";
|
|
798
|
+
const offendingText = context?.contentToSpeak ?? "";
|
|
799
|
+
const offendingPreview = offendingText
|
|
800
|
+
.replace(/\s+/g, " ")
|
|
801
|
+
.trim()
|
|
802
|
+
.slice(0, 120);
|
|
803
|
+
const offendingDiagnostic = offendingText.length > 0
|
|
804
|
+
? {
|
|
805
|
+
textLength: offendingText.length,
|
|
806
|
+
textPreview: offendingPreview.length < offendingText.length
|
|
807
|
+
? `${offendingPreview}…`
|
|
808
|
+
: offendingPreview,
|
|
809
|
+
}
|
|
810
|
+
: {};
|
|
724
811
|
try {
|
|
725
812
|
this.provider?.stop();
|
|
726
813
|
this.currentProvider.destroy();
|
|
@@ -729,21 +816,23 @@ export class TTSService {
|
|
|
729
816
|
this.ttsConfig = browserConfig;
|
|
730
817
|
await this.emitTelemetry("pie-tool-runtime-fallback", {
|
|
731
818
|
toolId: "textToSpeech",
|
|
732
|
-
operation
|
|
819
|
+
operation,
|
|
733
820
|
fromProvider: previousProviderId,
|
|
734
821
|
toProvider: fallbackProvider.providerId,
|
|
735
822
|
reason: reason instanceof Error ? reason.message : String(reason),
|
|
823
|
+
...offendingDiagnostic,
|
|
736
824
|
});
|
|
737
|
-
console.warn("[TTSService]
|
|
825
|
+
console.warn("[TTSService] TTS provider initialization failed; switched to browser fallback", {
|
|
738
826
|
fromProvider: previousProviderId,
|
|
739
827
|
reason,
|
|
828
|
+
...offendingDiagnostic,
|
|
740
829
|
});
|
|
741
830
|
return true;
|
|
742
831
|
}
|
|
743
832
|
catch (fallbackError) {
|
|
744
833
|
await this.emitTelemetry("pie-tool-runtime-fallback-error", {
|
|
745
834
|
toolId: "textToSpeech",
|
|
746
|
-
operation
|
|
835
|
+
operation,
|
|
747
836
|
fromProvider: previousProviderId,
|
|
748
837
|
toProvider: "browser",
|
|
749
838
|
errorType: "TTSRuntimeFallbackError",
|
|
@@ -760,36 +849,13 @@ export class TTSService {
|
|
|
760
849
|
return false;
|
|
761
850
|
if (!("speechSynthesis" in window))
|
|
762
851
|
return false;
|
|
763
|
-
return typeof globalThis
|
|
764
|
-
|
|
765
|
-
async retryWithBrowserFallback(args) {
|
|
766
|
-
if (!this.shouldAttemptBrowserFallback(args.error))
|
|
767
|
-
return false;
|
|
768
|
-
if (!this.isBrowserSpeechFallbackAvailable())
|
|
769
|
-
return false;
|
|
770
|
-
const switched = await this.switchProviderToBrowser(args.error);
|
|
771
|
-
if (!switched)
|
|
772
|
-
return false;
|
|
773
|
-
if (args.runId !== this.speakRunId)
|
|
774
|
-
return true;
|
|
775
|
-
// Rebind highlight callbacks to the newly initialized provider, then retry once.
|
|
776
|
-
this.configureWordBoundaryHighlighting({
|
|
777
|
-
highlightMode: args.highlightMode,
|
|
778
|
-
wordBoundaryOffset: args.wordBoundaryOffset,
|
|
779
|
-
});
|
|
780
|
-
await this.executeSpeakPlayback({
|
|
781
|
-
shouldUsePlan: args.shouldUsePlan,
|
|
782
|
-
runId: args.runId,
|
|
783
|
-
highlightMode: args.highlightMode,
|
|
784
|
-
contentToSpeak: args.contentToSpeak,
|
|
785
|
-
});
|
|
786
|
-
return true;
|
|
852
|
+
return (typeof globalThis
|
|
853
|
+
.SpeechSynthesisUtterance === "function");
|
|
787
854
|
}
|
|
788
855
|
async applyLanguageSettings(options) {
|
|
789
856
|
if (!options?.language || !this.provider)
|
|
790
857
|
return;
|
|
791
|
-
const providerOptions = (this.ttsConfig.providerOptions ||
|
|
792
|
-
{});
|
|
858
|
+
const providerOptions = (this.ttsConfig.providerOptions || {});
|
|
793
859
|
const textNormalization = (providerOptions.textNormalization ||
|
|
794
860
|
{});
|
|
795
861
|
const segmenter = (providerOptions.segmenter || {});
|
|
@@ -815,55 +881,260 @@ export class TTSService {
|
|
|
815
881
|
await this.provider.updateSettings({ providerOptions: mergedProviderOptions });
|
|
816
882
|
}
|
|
817
883
|
}
|
|
818
|
-
resolveSpeechContent(text, options) {
|
|
884
|
+
async resolveSpeechContent(text, options) {
|
|
819
885
|
const normalizedInputText = normalizeTextForSpeech(text);
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
normalizedInputText
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
886
|
+
if (options?.ignoreCatalogs) {
|
|
887
|
+
return {
|
|
888
|
+
contentToSpeak: normalizedInputText,
|
|
889
|
+
speechText: normalizedInputText,
|
|
890
|
+
visibleText: normalizedInputText,
|
|
891
|
+
highlightText: normalizedInputText,
|
|
892
|
+
usedCatalogSpoken: false,
|
|
893
|
+
speechSource: "dom-or-input",
|
|
894
|
+
normalizedText: normalizedInputText,
|
|
895
|
+
containsMathMarkup: false,
|
|
896
|
+
speechMatchesVisibleText: true,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
827
899
|
if (options?.catalogId && this.catalogResolver) {
|
|
828
900
|
const catalogContent = this.catalogResolver.getAlternative(options.catalogId, {
|
|
829
901
|
type: "spoken",
|
|
830
902
|
language: options.language || "en-US",
|
|
831
903
|
useFallback: true,
|
|
904
|
+
context: options.catalogContext,
|
|
832
905
|
});
|
|
833
906
|
if (catalogContent) {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
907
|
+
const visibleText = options?.contentElement
|
|
908
|
+
? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
|
|
909
|
+
: normalizedInputText;
|
|
910
|
+
const normalizedCatalogText = normalizeTextForSpeech(catalogContent.content);
|
|
911
|
+
this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
|
|
912
|
+
return {
|
|
913
|
+
contentToSpeak: catalogContent.content,
|
|
914
|
+
speechText: catalogContent.content,
|
|
915
|
+
visibleText,
|
|
916
|
+
highlightText: visibleText,
|
|
917
|
+
usedCatalogSpoken: true,
|
|
918
|
+
speechSource: "catalog-spoken",
|
|
919
|
+
normalizedText: visibleText,
|
|
920
|
+
containsMathMarkup: false,
|
|
921
|
+
speechMatchesVisibleText: normalizedCatalogText === visibleText,
|
|
922
|
+
};
|
|
841
923
|
}
|
|
924
|
+
this.debugLog(`[TTSService] No catalog found for "${options.catalogId}", falling back to generated TTS`);
|
|
842
925
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
926
|
+
const composed = options?.contentElement
|
|
927
|
+
? this.resolveCatalogComposedSpeechContent(options.contentElement, normalizedInputText, options)
|
|
928
|
+
: null;
|
|
929
|
+
if (composed)
|
|
930
|
+
return composed;
|
|
931
|
+
const generated = options?.contentElement
|
|
932
|
+
? await this.resolveGeneratedSpeechContent(options.contentElement, normalizedInputText, options.language)
|
|
933
|
+
: {
|
|
934
|
+
contentToSpeak: normalizedInputText,
|
|
935
|
+
speechText: normalizedInputText,
|
|
936
|
+
visibleText: normalizedInputText,
|
|
937
|
+
highlightText: normalizedInputText,
|
|
938
|
+
normalizedText: normalizedInputText,
|
|
939
|
+
containsMathMarkup: false,
|
|
940
|
+
speechMatchesVisibleText: true,
|
|
941
|
+
};
|
|
942
|
+
return {
|
|
943
|
+
...generated,
|
|
944
|
+
usedCatalogSpoken: false,
|
|
945
|
+
speechSource: "dom-or-input",
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
resolveCatalogComposedSpeechContent(contentElement, normalizedInputText, options) {
|
|
949
|
+
if (!this.catalogResolver)
|
|
950
|
+
return null;
|
|
951
|
+
const chunks = this.collectCatalogSpeechChunks(contentElement, options);
|
|
952
|
+
if (!chunks.some((chunk) => chunk.sourceElement))
|
|
953
|
+
return null;
|
|
954
|
+
const speechText = normalizeTextForSpeech(chunks.map((chunk) => chunk.speechText).join(" "));
|
|
955
|
+
const visibleText = collectMathAwareTextAndMap(contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText;
|
|
956
|
+
return {
|
|
957
|
+
contentToSpeak: speechText,
|
|
958
|
+
speechText,
|
|
959
|
+
visibleText,
|
|
960
|
+
highlightText: visibleText,
|
|
961
|
+
usedCatalogSpoken: true,
|
|
962
|
+
speechSource: "catalog-spoken",
|
|
963
|
+
normalizedText: visibleText,
|
|
964
|
+
containsMathMarkup: false,
|
|
965
|
+
speechMatchesVisibleText: chunks.every((chunk) => chunk.speechMatchesVisibleText),
|
|
966
|
+
speechChunks: chunks,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
collectCatalogSpeechChunks(root, options) {
|
|
970
|
+
const chunks = [];
|
|
971
|
+
let textBuffer = "";
|
|
972
|
+
const flushTextBuffer = () => {
|
|
973
|
+
const visibleText = normalizeTextForSpeech(textBuffer);
|
|
974
|
+
textBuffer = "";
|
|
975
|
+
if (!visibleText)
|
|
976
|
+
return;
|
|
977
|
+
chunks.push({
|
|
978
|
+
speechText: visibleText,
|
|
979
|
+
visibleText,
|
|
980
|
+
sourceElement: null,
|
|
981
|
+
speechMatchesVisibleText: true,
|
|
982
|
+
});
|
|
983
|
+
};
|
|
984
|
+
const resolveCatalog = (element) => {
|
|
985
|
+
const catalogIdRef = element.getAttribute("data-catalog-idref");
|
|
986
|
+
if (!catalogIdRef)
|
|
987
|
+
return null;
|
|
988
|
+
return this.catalogResolver.getAlternative(catalogIdRef, {
|
|
989
|
+
type: "spoken",
|
|
990
|
+
language: options.language || "en-US",
|
|
991
|
+
useFallback: true,
|
|
992
|
+
context: options.catalogContext,
|
|
993
|
+
});
|
|
994
|
+
};
|
|
995
|
+
const getSingleMathElementForAlignment = (element, visibleText) => {
|
|
996
|
+
const mathElements = getMathElementsForAlignment(element);
|
|
997
|
+
if (mathElements.length !== 1)
|
|
998
|
+
return null;
|
|
999
|
+
const mathElement = mathElements[0];
|
|
1000
|
+
const mathVisibleText = collectMathAwareTextAndMap(mathElement, this.getTextProcessingOptions(options.language)).visibleText || normalizeTextForSpeech(mathElement.textContent || "");
|
|
1001
|
+
const compact = (value) => normalizeTextForSpeech(value).replace(/\s+/g, "");
|
|
1002
|
+
return compact(mathVisibleText) === compact(visibleText)
|
|
1003
|
+
? mathElement
|
|
1004
|
+
: null;
|
|
1005
|
+
};
|
|
1006
|
+
const getMathElementsForAlignment = (element) => {
|
|
1007
|
+
const mathElements = Array.from(element.querySelectorAll("math"));
|
|
1008
|
+
if (element.localName?.toLowerCase() === "math") {
|
|
1009
|
+
return [element, ...mathElements];
|
|
1010
|
+
}
|
|
1011
|
+
return mathElements;
|
|
1012
|
+
};
|
|
1013
|
+
const visit = (node) => {
|
|
1014
|
+
if (isNodeHiddenForTTS(node, root))
|
|
1015
|
+
return;
|
|
1016
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1017
|
+
textBuffer += ` ${node.textContent || ""}`;
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
1021
|
+
return;
|
|
1022
|
+
const element = node;
|
|
1023
|
+
const catalog = resolveCatalog(element);
|
|
1024
|
+
if (catalog) {
|
|
1025
|
+
flushTextBuffer();
|
|
1026
|
+
const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
|
|
1027
|
+
const visibleText = collectedVisible.visibleText ||
|
|
1028
|
+
normalizeTextForSpeech(element.textContent || "");
|
|
1029
|
+
const alignment = createCatalogSpanAlignment({
|
|
1030
|
+
speechText: catalog.content,
|
|
1031
|
+
visibleText,
|
|
1032
|
+
});
|
|
1033
|
+
const mathElement = getSingleMathElementForAlignment(element, visibleText);
|
|
1034
|
+
const mathAlignment = mathElement
|
|
1035
|
+
? createMathAwareAlignment({
|
|
1036
|
+
mathElement,
|
|
1037
|
+
speechText: catalog.content,
|
|
1038
|
+
})
|
|
1039
|
+
: undefined;
|
|
1040
|
+
const mathAlignments = mathAlignment
|
|
1041
|
+
? undefined
|
|
1042
|
+
: getMathElementsForAlignment(element).map((candidate) => ({
|
|
1043
|
+
element: candidate,
|
|
1044
|
+
alignment: createMathAwareAlignment({
|
|
1045
|
+
mathElement: candidate,
|
|
1046
|
+
speechText: catalog.content,
|
|
1047
|
+
}),
|
|
1048
|
+
}));
|
|
1049
|
+
const speechText = normalizeTextForSpeech(catalog.content);
|
|
1050
|
+
chunks.push({
|
|
1051
|
+
speechText: catalog.content,
|
|
1052
|
+
visibleText,
|
|
1053
|
+
sourceElement: element,
|
|
1054
|
+
regionElement: resolveReadableRegion(element, root),
|
|
1055
|
+
speechMatchesVisibleText: speechText === visibleText,
|
|
1056
|
+
playbackMode: alignment.playbackMode,
|
|
1057
|
+
alignment,
|
|
1058
|
+
mathAlignment,
|
|
1059
|
+
mathAlignments,
|
|
1060
|
+
visibleMap: collectedVisible.map,
|
|
851
1061
|
});
|
|
852
|
-
|
|
853
|
-
usedCatalogSpoken = false;
|
|
854
|
-
speechSource = "dom-or-input";
|
|
1062
|
+
return;
|
|
855
1063
|
}
|
|
1064
|
+
for (const child of Array.from(element.childNodes)) {
|
|
1065
|
+
visit(child);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
visit(root);
|
|
1069
|
+
flushTextBuffer();
|
|
1070
|
+
return chunks;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Decide the playback format for the generated (no authored catalog) math
|
|
1074
|
+
* path. SSML is only emitted to providers that report `supportsSSML`: the
|
|
1075
|
+
* browser Web Speech API speaks tags literally (capability `false`), and the
|
|
1076
|
+
* server bridge reports `true` only for SSML-reliable backends (Polly,
|
|
1077
|
+
* Google) — the `custom`/SchoolCity transport stays plain.
|
|
1078
|
+
*
|
|
1079
|
+
* A speak-time plain fallback (`SpeechCompositionChunk.plainFallback`) is the
|
|
1080
|
+
* defensive net if a capable provider still rejects a given SSML payload.
|
|
1081
|
+
*/
|
|
1082
|
+
resolveGeneratedPlaybackFormat() {
|
|
1083
|
+
const provider = this.currentProvider;
|
|
1084
|
+
if (!provider)
|
|
1085
|
+
return "plain";
|
|
1086
|
+
// Belt-and-suspenders: the browser provider can never voice SSML.
|
|
1087
|
+
if (provider.providerId?.toLowerCase() === "browser")
|
|
1088
|
+
return "plain";
|
|
1089
|
+
return provider.getCapabilities?.().supportsSSML ? "ssml" : "plain";
|
|
1090
|
+
}
|
|
1091
|
+
async resolveGeneratedSpeechContent(contentElement, normalizedInputText, language) {
|
|
1092
|
+
// Delegate to the generated-speech module: a pure plan assembly + SRE
|
|
1093
|
+
// memoization core, plus a DOM adapter that binds live-DOM anchors and
|
|
1094
|
+
// emits playback chunks. The aggregate `contentToSpeak` stays plain text
|
|
1095
|
+
// (`plan.plainSpeechText`) so seek/structural-pause planning is unaffected.
|
|
1096
|
+
const playbackFormat = this.resolveGeneratedPlaybackFormat();
|
|
1097
|
+
const { plan, containsMathMarkup, visibleText: collectedVisibleText } = await buildGeneratedSpeechFromRoot({
|
|
1098
|
+
contentRoot: contentElement,
|
|
1099
|
+
language,
|
|
1100
|
+
mathSpeech: this.getMathSpeechOptions(),
|
|
1101
|
+
textProcessingOptions: this.getTextProcessingOptions(language),
|
|
1102
|
+
produceSsml: playbackFormat === "ssml",
|
|
1103
|
+
resolveMathSpeech: this.generatedMathSpeechResolver,
|
|
1104
|
+
});
|
|
1105
|
+
const visibleText = collectedVisibleText || normalizedInputText;
|
|
1106
|
+
if (!containsMathMarkup) {
|
|
1107
|
+
return {
|
|
1108
|
+
contentToSpeak: visibleText,
|
|
1109
|
+
speechText: visibleText,
|
|
1110
|
+
visibleText,
|
|
1111
|
+
highlightText: visibleText,
|
|
1112
|
+
normalizedText: visibleText,
|
|
1113
|
+
containsMathMarkup: false,
|
|
1114
|
+
speechMatchesVisibleText: true,
|
|
1115
|
+
};
|
|
856
1116
|
}
|
|
1117
|
+
const speechChunks = planToCompositionChunkInputs(plan, {
|
|
1118
|
+
format: playbackFormat,
|
|
1119
|
+
});
|
|
1120
|
+
const speechText = plan.plainSpeechText;
|
|
857
1121
|
return {
|
|
858
|
-
contentToSpeak,
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1122
|
+
contentToSpeak: speechText,
|
|
1123
|
+
speechText,
|
|
1124
|
+
visibleText,
|
|
1125
|
+
highlightText: visibleText,
|
|
1126
|
+
normalizedText: visibleText,
|
|
1127
|
+
containsMathMarkup: true,
|
|
1128
|
+
speechMatchesVisibleText: speechText === visibleText,
|
|
1129
|
+
speechChunks,
|
|
862
1130
|
};
|
|
863
1131
|
}
|
|
864
1132
|
logResolvedSpeechContent(args) {
|
|
865
|
-
const preview = args.contentToSpeak
|
|
866
|
-
|
|
1133
|
+
const preview = args.contentToSpeak
|
|
1134
|
+
.replace(/\s+/g, " ")
|
|
1135
|
+
.trim()
|
|
1136
|
+
.slice(0, 200);
|
|
1137
|
+
this.debugLog("[TTSService] Speak resolved content", {
|
|
867
1138
|
source: args.speechSource,
|
|
868
1139
|
catalogId: args.catalogId || null,
|
|
869
1140
|
length: args.contentToSpeak.length,
|
|
@@ -888,7 +1159,7 @@ export class TTSService {
|
|
|
888
1159
|
const initialSegment = this.sentenceHighlightSegments[0] || this.seekSegments[0];
|
|
889
1160
|
if (initialSegment) {
|
|
890
1161
|
this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
|
|
891
|
-
|
|
1162
|
+
this.debugLog("[TTSService] Applied initial sentence highlighting");
|
|
892
1163
|
}
|
|
893
1164
|
else {
|
|
894
1165
|
try {
|
|
@@ -917,7 +1188,7 @@ export class TTSService {
|
|
|
917
1188
|
const highlightRange = this.findHighlightRange(globalIndex, wordLength);
|
|
918
1189
|
if (highlightRange && this.highlightCoordinator) {
|
|
919
1190
|
const highlightText = highlightRange.node.textContent?.substring(highlightRange.start, highlightRange.end) || "";
|
|
920
|
-
|
|
1191
|
+
this.debugLog(`[TTSService] Highlighting "${highlightText}" (word: "${word}") at position ${globalIndex}`);
|
|
921
1192
|
this.highlightCoordinator.highlightTTSWord(highlightRange.node, highlightRange.start, highlightRange.end);
|
|
922
1193
|
}
|
|
923
1194
|
else {
|
|
@@ -925,7 +1196,221 @@ export class TTSService {
|
|
|
925
1196
|
}
|
|
926
1197
|
};
|
|
927
1198
|
}
|
|
1199
|
+
clearWordBoundaryHighlighting() {
|
|
1200
|
+
if (this.provider) {
|
|
1201
|
+
this.provider.onWordBoundary = undefined;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
highlightCatalogRegion(chunk) {
|
|
1205
|
+
const element = chunk.regionElement || chunk.sourceElement;
|
|
1206
|
+
if ((!element && !chunk.regionRange) || !this.highlightCoordinator)
|
|
1207
|
+
return;
|
|
1208
|
+
try {
|
|
1209
|
+
this.lastRenderedRegionTarget = null;
|
|
1210
|
+
if (chunk.regionRange) {
|
|
1211
|
+
this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
|
|
1212
|
+
this.highlightCoordinator.highlightTTSSentence([chunk.regionRange]);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (!element)
|
|
1216
|
+
return;
|
|
1217
|
+
const range = document.createRange();
|
|
1218
|
+
range.selectNodeContents(element);
|
|
1219
|
+
this.highlightCoordinator.clearHighlights?.(HighlightType.TTS_WORD);
|
|
1220
|
+
this.highlightCoordinator.highlightTTSSentence([range]);
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
// Continue playback even when a detached node cannot be highlighted.
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
highlightCatalogActiveRange(range) {
|
|
1227
|
+
if (!this.highlightCoordinator)
|
|
1228
|
+
return;
|
|
1229
|
+
if (range.startContainer === range.endContainer &&
|
|
1230
|
+
range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
1231
|
+
this.highlightCoordinator.highlightTTSWord(range.startContainer, range.startOffset, range.endOffset);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
this.highlightCoordinator.highlightRange(range, HighlightType.TTS_WORD, HighlightColor.YELLOW);
|
|
1235
|
+
}
|
|
1236
|
+
highlightRenderableRegionTarget(target) {
|
|
1237
|
+
if (!this.highlightCoordinator ||
|
|
1238
|
+
!target ||
|
|
1239
|
+
typeof document === "undefined") {
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
if (sameRenderableHighlightTarget(this.lastRenderedRegionTarget, target)) {
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
try {
|
|
1246
|
+
const range = document.createRange();
|
|
1247
|
+
if (target.type === "element") {
|
|
1248
|
+
range.selectNodeContents(target.element);
|
|
1249
|
+
}
|
|
1250
|
+
else if (target.type === "range") {
|
|
1251
|
+
this.highlightCoordinator.highlightTTSSentence([target.range]);
|
|
1252
|
+
this.lastRenderedRegionTarget = target;
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
else {
|
|
1256
|
+
range.setStart(target.node, target.startOffset);
|
|
1257
|
+
range.setEnd(target.node, target.endOffset);
|
|
1258
|
+
}
|
|
1259
|
+
this.highlightCoordinator.highlightTTSSentence([range]);
|
|
1260
|
+
this.lastRenderedRegionTarget = target;
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
// Continue playback even when a detached node cannot be highlighted.
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
highlightRenderableActiveTarget(target) {
|
|
1267
|
+
if (!this.highlightCoordinator || typeof document === "undefined") {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (!target) {
|
|
1271
|
+
// No active word for this boundary — an unresolved prose word, an SSML
|
|
1272
|
+
// break/pause, or a token-mode equation still awaiting its first token.
|
|
1273
|
+
// Clear the word layer so the previously spoken word does not stay
|
|
1274
|
+
// highlighted. (The "hold last token" behavior returns a non-null
|
|
1275
|
+
// target, so it does not reach here.)
|
|
1276
|
+
this.highlightCoordinator.clearHighlights(HighlightType.TTS_WORD);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (target.type === "text-range") {
|
|
1280
|
+
this.highlightCoordinator.highlightTTSWord(target.node, target.startOffset, target.endOffset);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (target.type === "range") {
|
|
1284
|
+
this.highlightCatalogActiveRange(target.range);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
// Element targets are atomic: a resolved math token, a whole-expression
|
|
1288
|
+
// fallback, or a replaced element (image/svg). Paint exactly the resolved
|
|
1289
|
+
// element. Native MathML / HTML tokens that expose a single text node keep
|
|
1290
|
+
// the CSS-range underline (it reads more precisely than an element box);
|
|
1291
|
+
// everything else — notably MathJax CHTML tokens (`<mjx-mi><mjx-c/>`),
|
|
1292
|
+
// which have no text node, and expression fallbacks — is marked on the
|
|
1293
|
+
// element itself. Crucially we no longer escalate to the enclosing
|
|
1294
|
+
// `<math>` / `<mjx-container>`: that escalation is why MathJax math only
|
|
1295
|
+
// ever highlighted as a full block while native MathML tracked per-token.
|
|
1296
|
+
const onlyChild = target.element.childNodes.length === 1 ? target.element.firstChild : null;
|
|
1297
|
+
if (target.quality === "semantic-token" &&
|
|
1298
|
+
onlyChild?.nodeType === Node.TEXT_NODE) {
|
|
1299
|
+
this.highlightCoordinator.highlightTTSWord(onlyChild, 0, onlyChild.textContent?.length || 0);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
this.highlightCoordinator.highlightTTSWordElement?.(target.element);
|
|
1303
|
+
}
|
|
1304
|
+
renderHighlightDecision(decision) {
|
|
1305
|
+
this.highlightRenderableRegionTarget(decision.regionTarget);
|
|
1306
|
+
this.highlightRenderableActiveTarget(decision.activeTarget);
|
|
1307
|
+
}
|
|
1308
|
+
async speakCatalogChunk(chunk, runId) {
|
|
1309
|
+
try {
|
|
1310
|
+
await this.speakCatalogChunkOnce(chunk, runId);
|
|
1311
|
+
}
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
// Speak-time fallback: if an SSML math chunk is rejected by the
|
|
1314
|
+
// provider, retry once with its precomputed plain-text variant (same
|
|
1315
|
+
// anchors, plain alignment). The fallback chunk carries no further
|
|
1316
|
+
// fallback, so this cannot recurse.
|
|
1317
|
+
if (chunk.plainFallback && runId === this.speakRunId) {
|
|
1318
|
+
this.debugLog("[TTSService] SSML chunk speak failed; retrying plain text", { message: error instanceof Error ? error.message : String(error) });
|
|
1319
|
+
await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
throw error;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
async speakCatalogChunkOnce(chunk, runId) {
|
|
1326
|
+
if (!this.provider)
|
|
1327
|
+
return;
|
|
1328
|
+
this.lastRenderedRegionTarget = null;
|
|
1329
|
+
const contentRoot = chunk.regionElement || chunk.sourceElement || this.currentContentElement;
|
|
1330
|
+
const pipelineChunk = contentRoot
|
|
1331
|
+
? normalizeSpeechChunks({
|
|
1332
|
+
contentRoot,
|
|
1333
|
+
chunks: [chunk],
|
|
1334
|
+
})[0]
|
|
1335
|
+
: null;
|
|
1336
|
+
// Carried through the config channel (see buildRuntimeTTSConfig); defaults
|
|
1337
|
+
// to per-token math highlighting unless a host explicitly disables it.
|
|
1338
|
+
const mathTokenHighlighting = this.ttsConfig.mathTokenHighlighting !== false;
|
|
1339
|
+
const highlightPlan = pipelineChunk
|
|
1340
|
+
? createTTSHighlightPlan({
|
|
1341
|
+
chunks: [pipelineChunk],
|
|
1342
|
+
mathTokenHighlighting,
|
|
1343
|
+
})
|
|
1344
|
+
: null;
|
|
1345
|
+
if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
|
|
1346
|
+
this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
this.highlightCatalogRegion(chunk);
|
|
1350
|
+
}
|
|
1351
|
+
const canUseChunkBoundaries = chunk.sourceElement &&
|
|
1352
|
+
((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
|
|
1353
|
+
(chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
|
|
1354
|
+
(chunk.alignment &&
|
|
1355
|
+
chunk.visibleMap &&
|
|
1356
|
+
(chunk.playbackMode === "exact-word" ||
|
|
1357
|
+
chunk.playbackMode === "anchor-span")));
|
|
1358
|
+
if (!canUseChunkBoundaries) {
|
|
1359
|
+
const provider = this.provider;
|
|
1360
|
+
const previousOnWordBoundary = provider.onWordBoundary;
|
|
1361
|
+
provider.onWordBoundary = undefined;
|
|
1362
|
+
try {
|
|
1363
|
+
await provider.speak(chunk.speechText);
|
|
1364
|
+
}
|
|
1365
|
+
finally {
|
|
1366
|
+
if (this.provider === provider &&
|
|
1367
|
+
runId === this.speakRunId &&
|
|
1368
|
+
provider.onWordBoundary === undefined) {
|
|
1369
|
+
provider.onWordBoundary = previousOnWordBoundary;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const provider = this.provider;
|
|
1375
|
+
const previousOnWordBoundary = provider.onWordBoundary;
|
|
1376
|
+
const installedHandler = (word, position, length) => {
|
|
1377
|
+
if (runId !== this.speakRunId)
|
|
1378
|
+
return;
|
|
1379
|
+
if (highlightPlan && pipelineChunk) {
|
|
1380
|
+
const providerOffsetSpace = pipelineChunk.offsetSpace === "unsupported"
|
|
1381
|
+
? "unknown"
|
|
1382
|
+
: pipelineChunk.offsetSpace;
|
|
1383
|
+
this.renderHighlightDecision(highlightPlan.resolveBoundary({
|
|
1384
|
+
chunkId: pipelineChunk.id,
|
|
1385
|
+
word,
|
|
1386
|
+
position,
|
|
1387
|
+
length,
|
|
1388
|
+
providerOffsetSpace,
|
|
1389
|
+
}));
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
provider.onWordBoundary = installedHandler;
|
|
1394
|
+
try {
|
|
1395
|
+
await provider.speak(chunk.speechText);
|
|
1396
|
+
}
|
|
1397
|
+
finally {
|
|
1398
|
+
if (this.provider === provider &&
|
|
1399
|
+
runId === this.speakRunId &&
|
|
1400
|
+
provider.onWordBoundary === installedHandler) {
|
|
1401
|
+
provider.onWordBoundary = previousOnWordBoundary;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
928
1405
|
async executeSpeakPlayback(args) {
|
|
1406
|
+
if (args.speechChunks?.length && this.provider) {
|
|
1407
|
+
for (const chunk of args.speechChunks) {
|
|
1408
|
+
if (args.runId !== this.speakRunId)
|
|
1409
|
+
return;
|
|
1410
|
+
await this.speakCatalogChunk(chunk, args.runId);
|
|
1411
|
+
}
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
929
1414
|
if (args.shouldUsePlan && this.currentContentElement) {
|
|
930
1415
|
const segments = this.seekSegments;
|
|
931
1416
|
if (segments.length > 0) {
|
|
@@ -946,6 +1431,7 @@ export class TTSService {
|
|
|
946
1431
|
if (this.highlightCoordinator) {
|
|
947
1432
|
this.highlightCoordinator.clearTTS();
|
|
948
1433
|
}
|
|
1434
|
+
this.lastRenderedRegionTarget = null;
|
|
949
1435
|
this.currentContentElement = null;
|
|
950
1436
|
this.normalizedToDOM.clear();
|
|
951
1437
|
this.currentBoundaryOffset = 0;
|
|
@@ -987,8 +1473,10 @@ export class TTSService {
|
|
|
987
1473
|
beforeRange.selectNodeContents(root);
|
|
988
1474
|
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
989
1475
|
const textBeforeRange = beforeRange.toString();
|
|
990
|
-
const
|
|
991
|
-
|
|
1476
|
+
const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
|
|
1477
|
+
const offset = normalizedTextBeforeRange.length +
|
|
1478
|
+
(/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
|
|
1479
|
+
this.debugLog("[TTSService] speakRange offset calculation:", {
|
|
992
1480
|
selectedText: text,
|
|
993
1481
|
textBeforeRange: textBeforeRange.substring(0, 100),
|
|
994
1482
|
offset,
|
|
@@ -997,6 +1485,7 @@ export class TTSService {
|
|
|
997
1485
|
// Speak the text with the root element as context
|
|
998
1486
|
await this.speak(text, {
|
|
999
1487
|
contentElement: root,
|
|
1488
|
+
ignoreCatalogs: true,
|
|
1000
1489
|
wordBoundaryOffset: offset,
|
|
1001
1490
|
});
|
|
1002
1491
|
}
|
|
@@ -1025,7 +1514,8 @@ export class TTSService {
|
|
|
1025
1514
|
async seekBy(units) {
|
|
1026
1515
|
if (!this.provider || !this.currentText)
|
|
1027
1516
|
return;
|
|
1028
|
-
if (this.state !== PlaybackState.PLAYING &&
|
|
1517
|
+
if (this.state !== PlaybackState.PLAYING &&
|
|
1518
|
+
this.state !== PlaybackState.PAUSED) {
|
|
1029
1519
|
return;
|
|
1030
1520
|
}
|
|
1031
1521
|
if (this.hasExplicitBreakSemantics(this.currentText))
|
|
@@ -1040,6 +1530,7 @@ export class TTSService {
|
|
|
1040
1530
|
if (targetIndex === currentIndex)
|
|
1041
1531
|
return;
|
|
1042
1532
|
this.speakRunId += 1;
|
|
1533
|
+
this.provider.onWordBoundary = undefined;
|
|
1043
1534
|
this.provider.stop();
|
|
1044
1535
|
this.currentSeekSegmentIndex = targetIndex;
|
|
1045
1536
|
const runId = ++this.speakRunId;
|
|
@@ -1078,6 +1569,7 @@ export class TTSService {
|
|
|
1078
1569
|
if (!this.provider)
|
|
1079
1570
|
return;
|
|
1080
1571
|
this.speakRunId += 1;
|
|
1572
|
+
this.provider.onWordBoundary = undefined;
|
|
1081
1573
|
this.provider.stop();
|
|
1082
1574
|
this.setState(PlaybackState.IDLE);
|
|
1083
1575
|
this.currentText = null;
|
|
@@ -1086,6 +1578,7 @@ export class TTSService {
|
|
|
1086
1578
|
this.highlightCoordinator.clearTTS();
|
|
1087
1579
|
}
|
|
1088
1580
|
// Clear tracking
|
|
1581
|
+
this.lastRenderedRegionTarget = null;
|
|
1089
1582
|
this.currentContentElement = null;
|
|
1090
1583
|
this.normalizedToDOM.clear();
|
|
1091
1584
|
this.currentBoundaryOffset = 0;
|