@pie-players/pie-assessment-toolkit 0.3.65 → 0.3.66
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 +36 -16
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +9 -8
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/{ItemToolBar-cckwpz6c.js → ItemToolBar-8jgdz50p.js} +9 -9
- package/dist/components/chunks/ItemToolBar-cvs646j3.js +36 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/policy/core/PolicySource.d.ts +2 -2
- package/dist/policy/core/PolicySource.js +2 -2
- package/dist/policy/core/ToolPolicyEngine.d.ts +7 -3
- package/dist/policy/core/ToolPolicyEngine.js +11 -3
- package/dist/policy/core/compose-decision.d.ts +3 -2
- package/dist/policy/core/compose-decision.js +3 -2
- package/dist/policy/core/decision-types.d.ts +2 -2
- package/dist/policy/core/decision-types.js +2 -2
- package/dist/policy/core/feature-decision.d.ts +28 -1
- package/dist/policy/core/feature-decision.js +25 -3
- package/dist/policy/core/pnp-policy-inputs.d.ts +2 -2
- package/dist/policy/core/pnp-policy-inputs.js +2 -2
- package/dist/policy/core/provenance.d.ts +4 -1
- package/dist/policy/core/provenance.js +4 -1
- package/dist/policy/sources/PnpPolicySource.d.ts +2 -1
- package/dist/policy/sources/PnpPolicySource.js +2 -1
- package/dist/runtime/core/engine-transition.js +3 -1
- package/dist/services/AccessibilityCatalogResolver.d.ts +49 -10
- package/dist/services/AccessibilityCatalogResolver.js +180 -11
- package/dist/services/TTSService.d.ts +11 -0
- package/dist/services/TTSService.js +220 -32
- package/dist/services/ToolRegistry.d.ts +55 -14
- package/dist/services/ToolRegistry.js +57 -3
- package/dist/services/ToolkitCoordinator.d.ts +18 -1
- package/dist/services/ToolkitCoordinator.js +24 -2
- package/dist/services/catalog-owner.d.ts +71 -0
- package/dist/services/catalog-owner.js +64 -0
- package/dist/services/framework-error.d.ts +1 -1
- package/dist/services/interfaces.d.ts +15 -3
- package/dist/services/tts/browser-provider.js +189 -23
- package/dist/tools/internal.d.ts +1 -0
- package/package.json +9 -9
- package/dist/components/chunks/ItemToolBar-pryf0rtz.js +0 -22
- package/dist/runtime/catalog-registration.d.ts +0 -67
- package/dist/runtime/catalog-registration.js +0 -86
|
@@ -296,7 +296,7 @@ export interface ToolkitServiceBundle {
|
|
|
296
296
|
* assessmentId: 'demo-three-questions',
|
|
297
297
|
* tools: {
|
|
298
298
|
* providers: {
|
|
299
|
-
* textToSpeech: { enabled: true,
|
|
299
|
+
* textToSpeech: { enabled: true, backend: 'browser' },
|
|
300
300
|
* answerEliminator: { enabled: true, strategy: 'strikethrough' }
|
|
301
301
|
* },
|
|
302
302
|
* placement: {
|
|
@@ -414,6 +414,17 @@ export declare class ToolkitCoordinator {
|
|
|
414
414
|
* frozen snapshot.
|
|
415
415
|
*/
|
|
416
416
|
private boundCurrentItemRef;
|
|
417
|
+
/**
|
|
418
|
+
* Whether {@link decideFeaturePolicy} has already reported serving a decision
|
|
419
|
+
* with no assessment bound.
|
|
420
|
+
*
|
|
421
|
+
* One report per coordinator, not per decision: a feature policy is consulted
|
|
422
|
+
* once per capability per card, so a per-decision warning would bury itself.
|
|
423
|
+
* Never reset — {@link updateAssessment} arriving later fixes the deployment,
|
|
424
|
+
* and re-arming would report the same gap again on the next unbound coordinator
|
|
425
|
+
* lifetime for no new information.
|
|
426
|
+
*/
|
|
427
|
+
private reportedUnboundFeaturePolicy;
|
|
417
428
|
private static resolveConfig;
|
|
418
429
|
constructor(config: ToolkitCoordinatorConfig);
|
|
419
430
|
/**
|
|
@@ -668,6 +679,12 @@ export declare class ToolkitCoordinator {
|
|
|
668
679
|
* Thin shim over the owned tool-policy engine; see
|
|
669
680
|
* {@link ToolPolicyEngine.decideFeature} for the contract, including why
|
|
670
681
|
* `pnpEnforcement` is not consulted.
|
|
682
|
+
*
|
|
683
|
+
* Reports once per coordinator when it is asked about a feature with no
|
|
684
|
+
* assessment bound. A host in that state gets a correct denial for every
|
|
685
|
+
* capability it asks about, which is indistinguishable from a student who was
|
|
686
|
+
* properly declined — so without this, forgetting {@link updateAssessment}
|
|
687
|
+
* presents as an accommodation that silently never appears.
|
|
671
688
|
*/
|
|
672
689
|
decideFeaturePolicy(featureId: string): FeaturePolicyDecision;
|
|
673
690
|
/**
|
|
@@ -70,7 +70,7 @@ const SECTION_SCOPED_EVENT_TYPES = [
|
|
|
70
70
|
* assessmentId: 'demo-three-questions',
|
|
71
71
|
* tools: {
|
|
72
72
|
* providers: {
|
|
73
|
-
* textToSpeech: { enabled: true,
|
|
73
|
+
* textToSpeech: { enabled: true, backend: 'browser' },
|
|
74
74
|
* answerEliminator: { enabled: true, strategy: 'strikethrough' }
|
|
75
75
|
* },
|
|
76
76
|
* placement: {
|
|
@@ -188,6 +188,17 @@ export class ToolkitCoordinator {
|
|
|
188
188
|
* frozen snapshot.
|
|
189
189
|
*/
|
|
190
190
|
boundCurrentItemRef = null;
|
|
191
|
+
/**
|
|
192
|
+
* Whether {@link decideFeaturePolicy} has already reported serving a decision
|
|
193
|
+
* with no assessment bound.
|
|
194
|
+
*
|
|
195
|
+
* One report per coordinator, not per decision: a feature policy is consulted
|
|
196
|
+
* once per capability per card, so a per-decision warning would bury itself.
|
|
197
|
+
* Never reset — {@link updateAssessment} arriving later fixes the deployment,
|
|
198
|
+
* and re-arming would report the same gap again on the next unbound coordinator
|
|
199
|
+
* lifetime for no new information.
|
|
200
|
+
*/
|
|
201
|
+
reportedUnboundFeaturePolicy = false;
|
|
191
202
|
static resolveConfig(config) {
|
|
192
203
|
const strictness = normalizeToolConfigStrictness(config.toolConfigStrictness);
|
|
193
204
|
// An empty registry when the host supplies none. This package no longer
|
|
@@ -1542,9 +1553,20 @@ export class ToolkitCoordinator {
|
|
|
1542
1553
|
* Thin shim over the owned tool-policy engine; see
|
|
1543
1554
|
* {@link ToolPolicyEngine.decideFeature} for the contract, including why
|
|
1544
1555
|
* `pnpEnforcement` is not consulted.
|
|
1556
|
+
*
|
|
1557
|
+
* Reports once per coordinator when it is asked about a feature with no
|
|
1558
|
+
* assessment bound. A host in that state gets a correct denial for every
|
|
1559
|
+
* capability it asks about, which is indistinguishable from a student who was
|
|
1560
|
+
* properly declined — so without this, forgetting {@link updateAssessment}
|
|
1561
|
+
* presents as an accommodation that silently never appears.
|
|
1545
1562
|
*/
|
|
1546
1563
|
decideFeaturePolicy(featureId) {
|
|
1547
|
-
|
|
1564
|
+
const decision = this.policyEngine.decideFeature(featureId);
|
|
1565
|
+
if (!decision.assessmentBound && !this.reportedUnboundFeaturePolicy) {
|
|
1566
|
+
this.reportedUnboundFeaturePolicy = true;
|
|
1567
|
+
console.warn(`[ToolkitCoordinator] Feature policy was asked about "${featureId}" with no assessment bound, so every capability will be declined for want of a profile to read. Call updateAssessment(...) with the assessment (its personalNeedsProfile, settings.districtPolicy and settings.testAdministration are what policy reads) before relying on any accommodation. Reported once per coordinator.`);
|
|
1568
|
+
}
|
|
1569
|
+
return decision;
|
|
1548
1570
|
}
|
|
1549
1571
|
/**
|
|
1550
1572
|
* Subscribe to policy-engine change events. Fires whenever the
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity and traversal rules for one mounted accessibility-catalog owner.
|
|
3
|
+
*
|
|
4
|
+
* The resolver is the public lifecycle boundary. This supporting module keeps
|
|
5
|
+
* its owner identity and direct-lookup helper separate from the runtime event
|
|
6
|
+
* adapter, while keeping the entity walk private to the resolver package.
|
|
7
|
+
*/
|
|
8
|
+
import type { AccessibilityCatalog } from "@pie-players/pie-players-shared/types";
|
|
9
|
+
export type CatalogOwnerKind = "global" | "passage" | "itemModel";
|
|
10
|
+
interface CatalogOwnerScopeContext {
|
|
11
|
+
assessmentId?: string;
|
|
12
|
+
sectionId?: string;
|
|
13
|
+
}
|
|
14
|
+
/** A valid resolver scope for a global, passage, or item/model owner. */
|
|
15
|
+
export type CatalogOwnerContext = (CatalogOwnerScopeContext & {
|
|
16
|
+
ownerKind: "global";
|
|
17
|
+
canonicalItemId?: never;
|
|
18
|
+
itemId?: never;
|
|
19
|
+
passageId?: never;
|
|
20
|
+
modelId?: never;
|
|
21
|
+
}) | (CatalogOwnerScopeContext & {
|
|
22
|
+
ownerKind: "passage";
|
|
23
|
+
passageId: string;
|
|
24
|
+
canonicalItemId?: never;
|
|
25
|
+
itemId?: never;
|
|
26
|
+
modelId?: never;
|
|
27
|
+
}) | (CatalogOwnerScopeContext & {
|
|
28
|
+
ownerKind: "itemModel";
|
|
29
|
+
itemId: string;
|
|
30
|
+
canonicalItemId?: string;
|
|
31
|
+
modelId?: string;
|
|
32
|
+
passageId?: never;
|
|
33
|
+
});
|
|
34
|
+
export interface CatalogOwnerRegistrationEntry {
|
|
35
|
+
context: CatalogOwnerContext;
|
|
36
|
+
catalogs: AccessibilityCatalog[];
|
|
37
|
+
}
|
|
38
|
+
/** The entity shape catalogs hang off: an item, or a passage. */
|
|
39
|
+
export interface CatalogSourceEntity {
|
|
40
|
+
accessibilityCatalogs?: AccessibilityCatalog[];
|
|
41
|
+
config?: {
|
|
42
|
+
extractedCatalogs?: AccessibilityCatalog[];
|
|
43
|
+
models?: Array<{
|
|
44
|
+
id?: string;
|
|
45
|
+
accessibilityCatalogs?: AccessibilityCatalog[];
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Who is rendering the entity — everything owner scoping is derived from. */
|
|
50
|
+
export interface CatalogOwnerIdentity {
|
|
51
|
+
kind: "item" | "passage";
|
|
52
|
+
/** The rendered instance id. */
|
|
53
|
+
itemId: string;
|
|
54
|
+
canonicalItemId?: string;
|
|
55
|
+
assessmentId?: string;
|
|
56
|
+
sectionId?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the lookup context for a direct resolver client such as inline TTS.
|
|
60
|
+
*
|
|
61
|
+
* Owner registration uses the same function, so direct readers cannot drift
|
|
62
|
+
* from the context the resolver files the entity under.
|
|
63
|
+
*/
|
|
64
|
+
export declare function catalogOwnerContextFor(owner: CatalogOwnerIdentity): CatalogOwnerContext;
|
|
65
|
+
/**
|
|
66
|
+
* Collect every catalog group carried by one owner in registration-precedence
|
|
67
|
+
* order. This is deliberately not exported from the package root: callers
|
|
68
|
+
* register the owner once through `AccessibilityCatalogResolver.registerOwner`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function collectOwnerCatalogRegistrations(entity: CatalogSourceEntity | null | undefined, owner: CatalogOwnerIdentity): CatalogOwnerRegistrationEntry[];
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity and traversal rules for one mounted accessibility-catalog owner.
|
|
3
|
+
*
|
|
4
|
+
* The resolver is the public lifecycle boundary. This supporting module keeps
|
|
5
|
+
* its owner identity and direct-lookup helper separate from the runtime event
|
|
6
|
+
* adapter, while keeping the entity walk private to the resolver package.
|
|
7
|
+
*/
|
|
8
|
+
const hasCatalogs = (catalogs) => Array.isArray(catalogs) && catalogs.length > 0;
|
|
9
|
+
/**
|
|
10
|
+
* Build the lookup context for a direct resolver client such as inline TTS.
|
|
11
|
+
*
|
|
12
|
+
* Owner registration uses the same function, so direct readers cannot drift
|
|
13
|
+
* from the context the resolver files the entity under.
|
|
14
|
+
*/
|
|
15
|
+
export function catalogOwnerContextFor(owner) {
|
|
16
|
+
if (owner.kind === "passage") {
|
|
17
|
+
return {
|
|
18
|
+
ownerKind: "passage",
|
|
19
|
+
assessmentId: owner.assessmentId,
|
|
20
|
+
sectionId: owner.sectionId,
|
|
21
|
+
passageId: owner.canonicalItemId || owner.itemId,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
ownerKind: "itemModel",
|
|
26
|
+
assessmentId: owner.assessmentId,
|
|
27
|
+
sectionId: owner.sectionId,
|
|
28
|
+
itemId: owner.itemId,
|
|
29
|
+
canonicalItemId: owner.canonicalItemId || owner.itemId,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Collect every catalog group carried by one owner in registration-precedence
|
|
34
|
+
* order. This is deliberately not exported from the package root: callers
|
|
35
|
+
* register the owner once through `AccessibilityCatalogResolver.registerOwner`.
|
|
36
|
+
*/
|
|
37
|
+
export function collectOwnerCatalogRegistrations(entity, owner) {
|
|
38
|
+
if (!entity)
|
|
39
|
+
return [];
|
|
40
|
+
const context = catalogOwnerContextFor(owner);
|
|
41
|
+
const registrations = [];
|
|
42
|
+
if (hasCatalogs(entity.accessibilityCatalogs)) {
|
|
43
|
+
registrations.push({ context, catalogs: entity.accessibilityCatalogs });
|
|
44
|
+
}
|
|
45
|
+
if (hasCatalogs(entity.config?.extractedCatalogs)) {
|
|
46
|
+
registrations.push({
|
|
47
|
+
context,
|
|
48
|
+
catalogs: entity.config.extractedCatalogs,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (owner.kind === "passage")
|
|
52
|
+
return registrations;
|
|
53
|
+
if (context.ownerKind !== "itemModel")
|
|
54
|
+
return registrations;
|
|
55
|
+
for (const model of entity.config?.models ?? []) {
|
|
56
|
+
if (!hasCatalogs(model.accessibilityCatalogs))
|
|
57
|
+
continue;
|
|
58
|
+
registrations.push({
|
|
59
|
+
context: { ...context, modelId: model.id },
|
|
60
|
+
catalogs: model.accessibilityCatalogs,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return registrations;
|
|
64
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
|
|
2
|
-
export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "unknown";
|
|
2
|
+
export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "tool-surface" | "unknown";
|
|
3
3
|
export type FrameworkErrorSeverity = "warning" | "error";
|
|
4
4
|
export interface FrameworkErrorModel {
|
|
5
5
|
kind: FrameworkErrorKind;
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* Part of PIE Assessment Toolkit.
|
|
9
9
|
*/
|
|
10
10
|
import type { I18nServiceApi } from "@pie-players/pie-players-shared/i18n";
|
|
11
|
-
import type {
|
|
11
|
+
import type { AccessibilityCatalog } from "@pie-players/pie-players-shared/types";
|
|
12
|
+
import type { AccessibilityCatalogResolver, CatalogChangeListener, CatalogLookupContext, CatalogLookupOptions, CatalogOwnerRegistration, CatalogOwnerView, CatalogStatistics, CatalogType, ResolvedCatalog } from "./AccessibilityCatalogResolver.js";
|
|
13
|
+
import type { CatalogOwnerContext } from "./catalog-owner.js";
|
|
12
14
|
import type { FrameworkErrorListener } from "./framework-error-bus.js";
|
|
15
|
+
import type { FrameworkErrorModel } from "./framework-error.js";
|
|
13
16
|
import type { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
|
|
14
17
|
import type { SectionControllerHandle, SectionItemEventSubscriptionArgs, SectionScopedEventSubscriptionArgs, SectionEventSubscriptionArgs, ToolkitCoordinatorHooks, ToolkitInitStatus } from "./ToolkitCoordinator.js";
|
|
15
18
|
import type { ThemeConfig } from "./ThemeProvider.js";
|
|
@@ -291,11 +294,15 @@ export interface AccessibilityCatalogResolverApi {
|
|
|
291
294
|
/**
|
|
292
295
|
* Add item-level catalogs (called when rendering a new item)
|
|
293
296
|
*/
|
|
294
|
-
addItemCatalogs(catalogs:
|
|
297
|
+
addItemCatalogs(catalogs: AccessibilityCatalog[]): void;
|
|
295
298
|
/**
|
|
296
299
|
* Register catalogs scoped to a mounted content owner.
|
|
297
300
|
*/
|
|
298
|
-
registerCatalogs
|
|
301
|
+
registerCatalogs(context: CatalogOwnerContext, catalogs: AccessibilityCatalog[]): () => void;
|
|
302
|
+
/** Register every catalog carried by one mounted item or passage. */
|
|
303
|
+
registerOwner(registration: CatalogOwnerRegistration): () => void;
|
|
304
|
+
/** Bind read and change observation to one catalog owner. */
|
|
305
|
+
forOwner(context: CatalogOwnerContext): CatalogOwnerView;
|
|
299
306
|
/**
|
|
300
307
|
* Clear item-level catalogs (called when leaving an item)
|
|
301
308
|
*/
|
|
@@ -586,6 +593,11 @@ export interface ToolkitCoordinatorApi {
|
|
|
586
593
|
* `<pie-assessment-toolkit>` consume the same bus.
|
|
587
594
|
*/
|
|
588
595
|
subscribeFrameworkErrors(listener: FrameworkErrorListener): () => void;
|
|
596
|
+
/**
|
|
597
|
+
* Report a pre-built framework warning or error through the canonical bus.
|
|
598
|
+
* Optional so structural host coordinators remain assignable.
|
|
599
|
+
*/
|
|
600
|
+
reportFrameworkError?(model: FrameworkErrorModel): void;
|
|
589
601
|
/**
|
|
590
602
|
* Resolve the visible tool set for a given placement level + scope.
|
|
591
603
|
* Returns the engine's full decision (visible tools, diagnostics,
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Part of PIE Assessment Toolkit.
|
|
8
8
|
*/
|
|
9
9
|
import { segmentSentences as segmentTextToSentences } from "./text-segmentation.js";
|
|
10
|
+
const NATIVE_START_TIMEOUT_MS = 5_000;
|
|
11
|
+
const VOICE_INVENTORY_TIMEOUT_MS = 2_000;
|
|
10
12
|
const normalizeLanguageCode = (value) => String(value || "")
|
|
11
13
|
.trim()
|
|
12
14
|
.toLowerCase();
|
|
@@ -16,11 +18,11 @@ const browserLanguage = () => {
|
|
|
16
18
|
: "";
|
|
17
19
|
return normalizeLanguageCode(navigatorLanguage || "en-US");
|
|
18
20
|
};
|
|
19
|
-
const findBrowserVoice = (voices,
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const findBrowserVoice = (voices, preferredVoice) => {
|
|
22
|
+
if (preferredVoice) {
|
|
23
|
+
return (voices.find((voice) => voice.voiceURI === preferredVoice) ||
|
|
24
|
+
voices.find((voice) => voice.name === preferredVoice) ||
|
|
25
|
+
null);
|
|
24
26
|
}
|
|
25
27
|
const language = browserLanguage();
|
|
26
28
|
const languagePrefix = language.split("-")[0] || "en";
|
|
@@ -95,9 +97,98 @@ class BrowserTTSProviderImpl {
|
|
|
95
97
|
_isPlaying = false;
|
|
96
98
|
_isPaused = false;
|
|
97
99
|
speakRunId = 0;
|
|
100
|
+
settlePendingVoiceWait = null;
|
|
101
|
+
settlePendingChunk = null;
|
|
102
|
+
onPlaybackStart = undefined;
|
|
98
103
|
constructor(config) {
|
|
99
104
|
this.config = config;
|
|
100
105
|
}
|
|
106
|
+
waitForBrowserVoices(runId, configuredVoice) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const synth = speechSynthesis;
|
|
109
|
+
const canUseEventTarget = typeof synth.addEventListener === "function" &&
|
|
110
|
+
typeof synth.removeEventListener === "function";
|
|
111
|
+
const previousHandler = canUseEventTarget ? null : synth.onvoiceschanged;
|
|
112
|
+
let settled = false;
|
|
113
|
+
let timeout = null;
|
|
114
|
+
let pendingSettlement = null;
|
|
115
|
+
const cleanup = () => {
|
|
116
|
+
if (timeout !== null) {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
timeout = null;
|
|
119
|
+
}
|
|
120
|
+
if (canUseEventTarget) {
|
|
121
|
+
synth.removeEventListener("voiceschanged", onVoicesChanged);
|
|
122
|
+
}
|
|
123
|
+
else if (synth.onvoiceschanged === propertyHandler) {
|
|
124
|
+
synth.onvoiceschanged = previousHandler;
|
|
125
|
+
}
|
|
126
|
+
if (pendingSettlement &&
|
|
127
|
+
this.settlePendingVoiceWait === pendingSettlement) {
|
|
128
|
+
this.settlePendingVoiceWait = null;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const finish = (voicesAvailable, error) => {
|
|
132
|
+
if (settled)
|
|
133
|
+
return;
|
|
134
|
+
settled = true;
|
|
135
|
+
cleanup();
|
|
136
|
+
if (error) {
|
|
137
|
+
reject(error);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
resolve(voicesAvailable);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const inspectVoiceInventory = () => {
|
|
144
|
+
if (runId !== this.speakRunId) {
|
|
145
|
+
finish(false);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (synth.getVoices().length > 0)
|
|
149
|
+
finish(true);
|
|
150
|
+
};
|
|
151
|
+
const onVoicesChanged = () => inspectVoiceInventory();
|
|
152
|
+
const propertyHandler = (event) => {
|
|
153
|
+
try {
|
|
154
|
+
previousHandler?.call(synth, event);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
inspectVoiceInventory();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
pendingSettlement = () => finish(false);
|
|
161
|
+
this.settlePendingVoiceWait = pendingSettlement;
|
|
162
|
+
timeout = setTimeout(() => finish(false, new Error(`Configured browser voice "${configuredVoice}" could not be resolved because the browser did not publish its voice inventory within ${VOICE_INVENTORY_TIMEOUT_MS / 1_000} seconds.`)), VOICE_INVENTORY_TIMEOUT_MS);
|
|
163
|
+
if (canUseEventTarget) {
|
|
164
|
+
synth.addEventListener("voiceschanged", onVoicesChanged);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
synth.onvoiceschanged = propertyHandler;
|
|
168
|
+
}
|
|
169
|
+
// Close the getVoices()/listener-registration race without polling.
|
|
170
|
+
inspectVoiceInventory();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async resolveBrowserVoice(runId) {
|
|
174
|
+
const configuredVoice = typeof this.config?.voice === "string" ? this.config.voice.trim() : "";
|
|
175
|
+
let voices = speechSynthesis.getVoices();
|
|
176
|
+
if (configuredVoice && voices.length === 0) {
|
|
177
|
+
const voicesAvailable = await this.waitForBrowserVoices(runId, configuredVoice);
|
|
178
|
+
if (!voicesAvailable || runId !== this.speakRunId) {
|
|
179
|
+
return { shouldContinue: false, voice: null };
|
|
180
|
+
}
|
|
181
|
+
voices = speechSynthesis.getVoices();
|
|
182
|
+
}
|
|
183
|
+
if (runId !== this.speakRunId) {
|
|
184
|
+
return { shouldContinue: false, voice: null };
|
|
185
|
+
}
|
|
186
|
+
const voice = findBrowserVoice(voices, configuredVoice || undefined);
|
|
187
|
+
if (configuredVoice && !voice) {
|
|
188
|
+
throw new Error(`Configured browser voice "${configuredVoice}" is unavailable. Select a voice exposed by this browser using its voiceURI or name.`);
|
|
189
|
+
}
|
|
190
|
+
return { shouldContinue: true, voice };
|
|
191
|
+
}
|
|
101
192
|
async speak(text) {
|
|
102
193
|
if (!this.config) {
|
|
103
194
|
throw new Error("TTS not initialized");
|
|
@@ -105,12 +196,15 @@ class BrowserTTSProviderImpl {
|
|
|
105
196
|
// Invalidate any in-flight run and cancel current utterance.
|
|
106
197
|
this.stop();
|
|
107
198
|
const runId = this.speakRunId;
|
|
199
|
+
const voiceResolution = await this.resolveBrowserVoice(runId);
|
|
200
|
+
if (!voiceResolution.shouldContinue)
|
|
201
|
+
return;
|
|
108
202
|
const chunks = this.splitIntoChunks(text);
|
|
109
203
|
for (const chunk of chunks) {
|
|
110
204
|
if (runId !== this.speakRunId) {
|
|
111
205
|
break;
|
|
112
206
|
}
|
|
113
|
-
const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId);
|
|
207
|
+
const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId, voiceResolution.voice);
|
|
114
208
|
if (!shouldContinue) {
|
|
115
209
|
break;
|
|
116
210
|
}
|
|
@@ -122,6 +216,9 @@ class BrowserTTSProviderImpl {
|
|
|
122
216
|
}
|
|
123
217
|
this.stop();
|
|
124
218
|
const runId = this.speakRunId;
|
|
219
|
+
const voiceResolution = await this.resolveBrowserVoice(runId);
|
|
220
|
+
if (!voiceResolution.shouldContinue)
|
|
221
|
+
return;
|
|
125
222
|
for (const segment of segments) {
|
|
126
223
|
if (runId !== this.speakRunId)
|
|
127
224
|
break;
|
|
@@ -129,7 +226,7 @@ class BrowserTTSProviderImpl {
|
|
|
129
226
|
for (const chunk of chunks) {
|
|
130
227
|
if (runId !== this.speakRunId)
|
|
131
228
|
break;
|
|
132
|
-
const shouldContinue = await this.speakChunk(chunk.text, segment.startOffset + chunk.offset, runId);
|
|
229
|
+
const shouldContinue = await this.speakChunk(chunk.text, segment.startOffset + chunk.offset, runId, voiceResolution.voice);
|
|
133
230
|
if (!shouldContinue)
|
|
134
231
|
break;
|
|
135
232
|
}
|
|
@@ -214,7 +311,7 @@ class BrowserTTSProviderImpl {
|
|
|
214
311
|
const match = slice.match(/^\s*([^\s]+)/);
|
|
215
312
|
return match?.[1]?.length || 1;
|
|
216
313
|
}
|
|
217
|
-
async speakChunk(chunkText, chunkOffset, runId) {
|
|
314
|
+
async speakChunk(chunkText, chunkOffset, runId, voice) {
|
|
218
315
|
return new Promise((resolve, reject) => {
|
|
219
316
|
if (runId !== this.speakRunId) {
|
|
220
317
|
resolve(false);
|
|
@@ -223,47 +320,99 @@ class BrowserTTSProviderImpl {
|
|
|
223
320
|
const utterance = new SpeechSynthesisUtterance(chunkText);
|
|
224
321
|
this.utterance = utterance;
|
|
225
322
|
// Apply config
|
|
226
|
-
const voice = findBrowserVoice(speechSynthesis.getVoices(), this.config?.voice);
|
|
227
323
|
if (voice && shouldAssignBrowserVoice(voice))
|
|
228
324
|
utterance.voice = voice;
|
|
229
325
|
if (this.config?.rate)
|
|
230
326
|
utterance.rate = this.config.rate;
|
|
231
327
|
if (this.config?.pitch)
|
|
232
328
|
utterance.pitch = this.config.pitch;
|
|
329
|
+
let didStart = false;
|
|
330
|
+
let settled = false;
|
|
331
|
+
let startTimeout = null;
|
|
332
|
+
let pendingSettlement = null;
|
|
233
333
|
const clearOwnedUtterance = () => {
|
|
234
334
|
if (this.utterance === utterance) {
|
|
235
335
|
this.utterance = null;
|
|
236
336
|
}
|
|
237
337
|
};
|
|
338
|
+
const clearStartTimeout = () => {
|
|
339
|
+
if (startTimeout !== null) {
|
|
340
|
+
clearTimeout(startTimeout);
|
|
341
|
+
startTimeout = null;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
const detachHandlers = () => {
|
|
345
|
+
utterance.onstart = null;
|
|
346
|
+
utterance.onend = null;
|
|
347
|
+
utterance.onerror = null;
|
|
348
|
+
utterance.onpause = null;
|
|
349
|
+
utterance.onresume = null;
|
|
350
|
+
utterance.onboundary = null;
|
|
351
|
+
};
|
|
352
|
+
const settle = (shouldContinue, error) => {
|
|
353
|
+
if (settled)
|
|
354
|
+
return;
|
|
355
|
+
settled = true;
|
|
356
|
+
clearStartTimeout();
|
|
357
|
+
detachHandlers();
|
|
358
|
+
clearOwnedUtterance();
|
|
359
|
+
if (pendingSettlement &&
|
|
360
|
+
this.settlePendingChunk === pendingSettlement) {
|
|
361
|
+
this.settlePendingChunk = null;
|
|
362
|
+
}
|
|
363
|
+
if (runId === this.speakRunId) {
|
|
364
|
+
this._isPlaying = false;
|
|
365
|
+
this._isPaused = false;
|
|
366
|
+
}
|
|
367
|
+
if (error) {
|
|
368
|
+
reject(error);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
resolve(shouldContinue);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const browserEngineStartError = (reason) => new Error(reason === "ended"
|
|
375
|
+
? "Browser speech synthesis ended before audio started. The browser speech engine may be unavailable or stuck; try another voice or restart the browser."
|
|
376
|
+
: `Browser speech synthesis did not start within ${NATIVE_START_TIMEOUT_MS / 1_000} seconds. The browser speech engine may be unavailable or stuck; try another voice or restart the browser.`);
|
|
377
|
+
pendingSettlement = () => settle(false);
|
|
378
|
+
this.settlePendingChunk = pendingSettlement;
|
|
238
379
|
utterance.onstart = () => {
|
|
239
|
-
if (runId !== this.speakRunId)
|
|
380
|
+
if (runId !== this.speakRunId) {
|
|
381
|
+
settle(false);
|
|
240
382
|
return;
|
|
383
|
+
}
|
|
384
|
+
didStart = true;
|
|
385
|
+
clearStartTimeout();
|
|
241
386
|
this._isPlaying = true;
|
|
242
387
|
this._isPaused = false;
|
|
388
|
+
try {
|
|
389
|
+
this.onPlaybackStart?.();
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
console.error("Browser TTS playback-start callback failed", error);
|
|
393
|
+
}
|
|
243
394
|
};
|
|
244
395
|
utterance.onend = () => {
|
|
245
|
-
clearOwnedUtterance();
|
|
246
396
|
if (runId !== this.speakRunId) {
|
|
247
|
-
|
|
397
|
+
settle(false);
|
|
248
398
|
return;
|
|
249
399
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
400
|
+
if (!didStart) {
|
|
401
|
+
settle(false, browserEngineStartError("ended"));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
settle(true);
|
|
253
405
|
};
|
|
254
406
|
utterance.onerror = (event) => {
|
|
255
|
-
clearOwnedUtterance();
|
|
256
407
|
if (runId !== this.speakRunId) {
|
|
257
|
-
|
|
408
|
+
settle(false);
|
|
258
409
|
return;
|
|
259
410
|
}
|
|
260
|
-
this._isPlaying = false;
|
|
261
|
-
this._isPaused = false;
|
|
262
411
|
if (event.error === "interrupted" || event.error === "canceled") {
|
|
263
|
-
|
|
412
|
+
settle(false);
|
|
264
413
|
return;
|
|
265
414
|
}
|
|
266
|
-
|
|
415
|
+
settle(false, new Error(`Speech synthesis error: ${event.error}`));
|
|
267
416
|
};
|
|
268
417
|
utterance.onpause = () => {
|
|
269
418
|
if (runId !== this.speakRunId)
|
|
@@ -300,7 +449,20 @@ class BrowserTTSProviderImpl {
|
|
|
300
449
|
console.log("[BrowserProvider] Calling onWordBoundary with word:", word, "at position:", absoluteBoundaryStart);
|
|
301
450
|
this.onWordBoundary(word, absoluteBoundaryStart, wordLength);
|
|
302
451
|
};
|
|
303
|
-
|
|
452
|
+
startTimeout = setTimeout(() => {
|
|
453
|
+
if (settled || didStart || runId !== this.speakRunId)
|
|
454
|
+
return;
|
|
455
|
+
settle(false, browserEngineStartError("timedOut"));
|
|
456
|
+
speechSynthesis.cancel();
|
|
457
|
+
}, NATIVE_START_TIMEOUT_MS);
|
|
458
|
+
try {
|
|
459
|
+
speechSynthesis.speak(utterance);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
settle(false, error instanceof Error
|
|
463
|
+
? error
|
|
464
|
+
: new Error("Browser speech synthesis failed to queue speech"));
|
|
465
|
+
}
|
|
304
466
|
});
|
|
305
467
|
}
|
|
306
468
|
pause() {
|
|
@@ -314,8 +476,12 @@ class BrowserTTSProviderImpl {
|
|
|
314
476
|
}
|
|
315
477
|
}
|
|
316
478
|
stop() {
|
|
479
|
+
const utterance = this.utterance;
|
|
480
|
+
const shouldCancel = this._isPlaying || utterance !== null;
|
|
317
481
|
this.speakRunId += 1;
|
|
318
|
-
|
|
482
|
+
this.settlePendingVoiceWait?.();
|
|
483
|
+
this.settlePendingChunk?.();
|
|
484
|
+
if (shouldCancel) {
|
|
319
485
|
speechSynthesis.cancel();
|
|
320
486
|
}
|
|
321
487
|
this.utterance = null;
|
package/dist/tools/internal.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export type { HostedToolContext, HostedToolSize, ResolvedToolContext, ToolActivation, ToolContentDependency, ToolContentDependencyContext, ToolModuleLoader, ToolProviderDescriptor, ToolRegistration, ToolRenderElement, ToolSingletonScope, ToolSurfaceRenderContext, ToolSurfaceRenderResult, ToolSurfaceServices, ToolToolbarButtonDefinition, ToolToolbarRenderResult, ToolWindowShellConfig, ToolbarContext, } from "../services/ToolRegistry.js";
|
|
22
22
|
export { ToolRegistry } from "../services/ToolRegistry.js";
|
|
23
|
+
export type { CatalogOwnerCard, CatalogOwnerSnapshot, } from "../services/AccessibilityCatalogResolver.js";
|
|
23
24
|
export type { ToolContext, ToolLevel } from "../services/tool-context.js";
|
|
24
25
|
export { hasChoiceInteraction, hasMathContent, hasReadableText, hasScienceContent, } from "../services/tool-context.js";
|
|
25
26
|
export { createScopedToolId } from "../services/tool-instance-id.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pie-players/pie-assessment-toolkit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.66",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "PIE assessment toolkit: composable services + reference implementation for assessment players and tool coordination",
|
|
6
6
|
"license": "MIT",
|
|
@@ -79,15 +79,15 @@
|
|
|
79
79
|
"test": "bun test"
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
|
-
"@pie-players/pie-calculator": "0.3.
|
|
83
|
-
"@pie-players/pie-context": "0.3.
|
|
84
|
-
"@pie-players/pie-players-shared": "0.3.
|
|
85
|
-
"@pie-players/pie-tts": "0.3.
|
|
82
|
+
"@pie-players/pie-calculator": "0.3.66",
|
|
83
|
+
"@pie-players/pie-context": "0.3.66",
|
|
84
|
+
"@pie-players/pie-players-shared": "0.3.66",
|
|
85
|
+
"@pie-players/pie-tts": "0.3.66",
|
|
86
86
|
"speech-rule-engine": "^5.0.0-rc.4"
|
|
87
87
|
},
|
|
88
88
|
"peerDependencies": {
|
|
89
|
-
"@pie-players/pie-calculator-desmos": "0.3.
|
|
90
|
-
"@pie-players/tts-client-server": "0.3.
|
|
89
|
+
"@pie-players/pie-calculator-desmos": "0.3.66",
|
|
90
|
+
"@pie-players/tts-client-server": "0.3.66"
|
|
91
91
|
},
|
|
92
92
|
"peerDependenciesMeta": {
|
|
93
93
|
"@pie-players/pie-calculator-desmos": {
|
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
"devDependencies": {
|
|
101
101
|
"@biomejs/biome": "^2.5.7",
|
|
102
102
|
"@happy-dom/global-registrator": "^20.11.1",
|
|
103
|
-
"@pie-players/pie-calculator-desmos": "0.3.
|
|
104
|
-
"@pie-players/tts-client-server": "0.3.
|
|
103
|
+
"@pie-players/pie-calculator-desmos": "0.3.66",
|
|
104
|
+
"@pie-players/tts-client-server": "0.3.66",
|
|
105
105
|
"svelte": "^5.56.8",
|
|
106
106
|
"typescript": "^5.9.3"
|
|
107
107
|
},
|