@pie-players/pie-assessment-toolkit 0.3.65 → 0.3.67

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.
Files changed (48) hide show
  1. package/README.md +36 -16
  2. package/dist/components/ItemToolBar.custom-element.js +1 -1
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +9 -8
  4. package/dist/components/SectionToolBar.custom-element.js +1 -1
  5. package/dist/components/chunks/{ItemToolBar-cckwpz6c.js → ItemToolBar-8jgdz50p.js} +9 -9
  6. package/dist/components/chunks/ItemToolBar-cvs646j3.js +36 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +2 -1
  9. package/dist/policy/core/PolicySource.d.ts +2 -2
  10. package/dist/policy/core/PolicySource.js +2 -2
  11. package/dist/policy/core/ToolPolicyEngine.d.ts +19 -3
  12. package/dist/policy/core/ToolPolicyEngine.js +39 -5
  13. package/dist/policy/core/compose-decision.d.ts +3 -2
  14. package/dist/policy/core/compose-decision.js +3 -2
  15. package/dist/policy/core/decision-types.d.ts +2 -2
  16. package/dist/policy/core/decision-types.js +2 -2
  17. package/dist/policy/core/feature-decision.d.ts +56 -3
  18. package/dist/policy/core/feature-decision.js +60 -3
  19. package/dist/policy/core/pnp-policy-inputs.d.ts +2 -2
  20. package/dist/policy/core/pnp-policy-inputs.js +2 -2
  21. package/dist/policy/core/provenance.d.ts +4 -1
  22. package/dist/policy/core/provenance.js +4 -1
  23. package/dist/policy/engine.d.ts +1 -1
  24. package/dist/policy/engine.js +1 -0
  25. package/dist/policy/sources/PnpPolicySource.d.ts +2 -1
  26. package/dist/policy/sources/PnpPolicySource.js +2 -1
  27. package/dist/runtime/core/engine-transition.js +3 -1
  28. package/dist/services/AccessibilityCatalogResolver.d.ts +49 -10
  29. package/dist/services/AccessibilityCatalogResolver.js +180 -11
  30. package/dist/services/TTSService.d.ts +11 -0
  31. package/dist/services/TTSService.js +220 -32
  32. package/dist/services/ToolRegistry.d.ts +55 -14
  33. package/dist/services/ToolRegistry.js +57 -3
  34. package/dist/services/ToolkitCoordinator.d.ts +18 -1
  35. package/dist/services/ToolkitCoordinator.js +24 -2
  36. package/dist/services/catalog-owner.d.ts +71 -0
  37. package/dist/services/catalog-owner.js +64 -0
  38. package/dist/services/framework-error.d.ts +1 -1
  39. package/dist/services/interfaces.d.ts +15 -3
  40. package/dist/services/tts/browser-provider.js +189 -23
  41. package/dist/tools/content-capability-resolution.d.ts +106 -0
  42. package/dist/tools/content-capability-resolution.js +136 -0
  43. package/dist/tools/internal.d.ts +3 -0
  44. package/dist/tools/internal.js +1 -0
  45. package/package.json +13 -9
  46. package/dist/components/chunks/ItemToolBar-pryf0rtz.js +0 -22
  47. package/dist/runtime/catalog-registration.d.ts +0 -67
  48. 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, defaultVoice: 'en-US' },
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, defaultVoice: 'en-US' },
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
- return this.policyEngine.decideFeature(featureId);
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 { AccessibilityCatalogResolver, CatalogChangeListener, CatalogLookupContext, CatalogLookupOptions, CatalogOwnerContext, CatalogStatistics, CatalogType, ResolvedCatalog } from "./AccessibilityCatalogResolver.js";
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: any[]): void;
297
+ addItemCatalogs(catalogs: AccessibilityCatalog[]): void;
295
298
  /**
296
299
  * Register catalogs scoped to a mounted content owner.
297
300
  */
298
- registerCatalogs?(context: CatalogOwnerContext, catalogs: any[]): () => void;
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, preferredName) => {
20
- if (preferredName) {
21
- const explicit = voices.find((voice) => voice.name === preferredName);
22
- if (explicit)
23
- return explicit;
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
- resolve(false);
397
+ settle(false);
248
398
  return;
249
399
  }
250
- this._isPlaying = false;
251
- this._isPaused = false;
252
- resolve(true);
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
- resolve(false);
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
- resolve(false);
412
+ settle(false);
264
413
  return;
265
414
  }
266
- reject(new Error(`Speech synthesis error: ${event.error}`));
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
- speechSynthesis.speak(utterance);
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
- if (this._isPlaying || this.utterance) {
482
+ this.settlePendingVoiceWait?.();
483
+ this.settlePendingChunk?.();
484
+ if (shouldCancel) {
319
485
  speechSynthesis.cancel();
320
486
  }
321
487
  this.utterance = null;