@fiodos/web-core 0.1.5 → 0.1.7

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.
@@ -401,20 +401,40 @@ function createWebVoiceAdapter(options = {}) {
401
401
  // later programmatic playback (after the network round-trip) is allowed
402
402
  // and audible even with the silent switch on.
403
403
  const ctx = ensureAudioCtx();
404
- if (!ctx)
405
- return;
406
- try {
407
- if (ctx.state === 'suspended')
408
- void ctx.resume();
409
- // A 1-frame silent buffer fully unlocks playback on iOS Safari.
410
- const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
411
- const source = ctx.createBufferSource();
412
- source.buffer = buffer;
413
- source.connect(ctx.destination);
414
- source.start(0);
404
+ if (ctx) {
405
+ try {
406
+ if (ctx.state === 'suspended')
407
+ void ctx.resume();
408
+ // A 1-frame silent buffer fully unlocks playback on iOS Safari.
409
+ const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
410
+ const source = ctx.createBufferSource();
411
+ source.buffer = buffer;
412
+ source.connect(ctx.destination);
413
+ source.start(0);
414
+ }
415
+ catch {
416
+ /* best-effort unlock */
417
+ }
415
418
  }
416
- catch {
417
- /* best-effort unlock */
419
+ // Unlock device TTS (SpeechSynthesis) too. The spoken reply happens AFTER
420
+ // the network round-trip outside this gesture — and Safari/Chrome stay
421
+ // SILENT for a first programmatic speak that wasn't preceded by a
422
+ // gesture-initiated one. Warm the engine here: kick off voice loading and
423
+ // speak a zero-volume blank utterance inside the tap so the real reply is
424
+ // allowed to play later.
425
+ if (!options.disableDeviceTtsFallback && typeof window !== 'undefined' && window.speechSynthesis) {
426
+ try {
427
+ const synth = window.speechSynthesis;
428
+ synth.getVoices(); // triggers async voice loading on Chrome
429
+ if (synth.paused)
430
+ synth.resume();
431
+ const warm = new SpeechSynthesisUtterance(' ');
432
+ warm.volume = 0;
433
+ synth.speak(warm);
434
+ }
435
+ catch {
436
+ /* best-effort unlock */
437
+ }
418
438
  }
419
439
  },
420
440
  async playAudioBase64Mp3(base64) {
@@ -456,20 +476,32 @@ function createWebVoiceAdapter(options = {}) {
456
476
  // Voices load lazily on Chrome — speaking before they exist is silent.
457
477
  const voices = await loadVoicesOnce(synth);
458
478
  return new Promise((resolve) => {
479
+ let keepAlive = null;
480
+ const cleanup = () => {
481
+ if (keepAlive) {
482
+ clearInterval(keepAlive);
483
+ keepAlive = null;
484
+ }
485
+ };
459
486
  try {
460
- // Clear any utterance the engine wedged on (a known Chrome failure
461
- // mode) and unpause before queuing the reply.
462
- if (synth.speaking || synth.pending)
463
- synth.cancel();
464
- if (synth.paused)
465
- synth.resume();
487
+ // Clear any previous/wedged queue. After a prior cancel() (e.g. the
488
+ // stopPlayback() fired when listening started) Chrome on macOS can sit
489
+ // in a stuck "paused" state that does NOT report paused===true, so a
490
+ // conditional resume() never fires and every later speak() is silent.
491
+ synth.cancel();
466
492
  const utter = new SpeechSynthesisUtterance(text);
467
493
  utter.lang = ttsOptions.locale;
468
494
  const voice = pickVoiceForLocale(voices, ttsOptions.locale);
469
495
  if (voice)
470
496
  utter.voice = voice;
497
+ utter.volume = 1;
498
+ utter.rate = 1;
499
+ utter.pitch = 1;
500
+ // Keep a strong ref so Chrome can't GC the utterance mid-speech.
471
501
  ttsUtterance = utter;
472
502
  const finish = () => {
503
+ cleanup();
504
+ utter.onstart = null;
473
505
  utter.onend = null;
474
506
  utter.onerror = null;
475
507
  if (ttsUtterance === utter)
@@ -479,8 +511,22 @@ function createWebVoiceAdapter(options = {}) {
479
511
  utter.onend = finish;
480
512
  utter.onerror = finish;
481
513
  synth.speak(utter);
514
+ // THE FIX: resume() unconditionally right after queuing. This un-wedges
515
+ // the engine from the silent stuck-paused state described above.
516
+ synth.resume();
517
+ // Chrome desktop stops speaking after ~15s; pulse pause/resume to keep
518
+ // long replies going, and stop the timer once it has finished.
519
+ keepAlive = setInterval(() => {
520
+ if (!synth.speaking && !synth.pending) {
521
+ cleanup();
522
+ return;
523
+ }
524
+ synth.pause();
525
+ synth.resume();
526
+ }, 9000);
482
527
  }
483
528
  catch {
529
+ cleanup();
484
530
  resolve();
485
531
  }
486
532
  });
@@ -75,6 +75,7 @@ export declare class AgentController {
75
75
  private watchdog;
76
76
  private confirmWindow;
77
77
  private speakTimer;
78
+ private serverTtsUnavailable;
78
79
  private pendingConsentIntent;
79
80
  private readonly listeners;
80
81
  private unsubscribeSpeech;
@@ -54,6 +54,9 @@ class AgentController {
54
54
  // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
55
55
  // never reports completion (e.g. a stalled SpeechSynthesis engine).
56
56
  this.speakTimer = null;
57
+ // Once the backend shows it has no server-side TTS (a reply arrives with null
58
+ // audio), skip the extra /preview-tts round-trip and speak via device voice.
59
+ this.serverTtsUnavailable = false;
57
60
  this.pendingConsentIntent = null;
58
61
  this.listeners = new Set();
59
62
  this.unsubscribeSpeech = null;
@@ -90,7 +93,11 @@ class AgentController {
90
93
  });
91
94
  this.screenStore = (0, screenContextStore_1.createScreenContextStore)({
92
95
  getCurrentRoute: () => config.navigation.getCurrentRoute(),
93
- routeFallback: config.routeContextFallback,
96
+ // The integrator's fallback wins; otherwise the orb gets screen awareness
97
+ // out of the box from the matched manifest route (label + description +
98
+ // available actions). Null when no route matches → raw route, as today.
99
+ routeFallback: config.routeContextFallback ??
100
+ ((route) => (0, core_1.defaultRouteContextText)(route, config.manifest, this.messages)),
94
101
  });
95
102
  this.speech = (0, speechSession_1.createSpeechSession)({
96
103
  adapter: config.voice,
@@ -197,13 +204,20 @@ class AgentController {
197
204
  this.setPhase('speaking');
198
205
  try {
199
206
  let audio = audioBase64;
200
- if (!audio && text) {
201
- // A server-TTS failure must NOT skip the device-voice fallback below.
207
+ if (audio) {
208
+ this.serverTtsUnavailable = false; // server voice is working
209
+ }
210
+ // Once we know the backend ships no server TTS, skip the dead
211
+ // /preview-tts round-trip (pure latency) and use the device voice.
212
+ if (!audio && text && !this.serverTtsUnavailable) {
202
213
  try {
203
214
  audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
215
+ if (!audio)
216
+ this.serverTtsUnavailable = true;
204
217
  }
205
218
  catch {
206
219
  audio = null;
220
+ this.serverTtsUnavailable = true;
207
221
  }
208
222
  }
209
223
  const playback = audio
@@ -418,6 +432,10 @@ class AgentController {
418
432
  }, { signal: controller.signal });
419
433
  reply = turn.reply;
420
434
  audioBase64 = turn.audioBase64;
435
+ // Learn whether this backend ships server-side TTS: a non-empty reply
436
+ // with no audio means it doesn't — later lines then speak instantly.
437
+ if (reply)
438
+ this.serverTtsUnavailable = !audioBase64;
421
439
  const decision = await (0, turnEngine_1.decideAction)({
422
440
  action: turn.action,
423
441
  manifest: this.config.manifest,
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.5";
8
+ export declare const SDK_VERSION = "0.1.7";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -8,5 +8,5 @@ exports.SDK_PACKAGE = exports.SDK_VERSION = void 0;
8
8
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
9
9
  * by hand — change package.json `version` and re-run the sync/release script.
10
10
  */
11
- exports.SDK_VERSION = '0.1.5';
11
+ exports.SDK_VERSION = '0.1.7';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';
@@ -398,20 +398,40 @@ export function createWebVoiceAdapter(options = {}) {
398
398
  // later programmatic playback (after the network round-trip) is allowed
399
399
  // and audible even with the silent switch on.
400
400
  const ctx = ensureAudioCtx();
401
- if (!ctx)
402
- return;
403
- try {
404
- if (ctx.state === 'suspended')
405
- void ctx.resume();
406
- // A 1-frame silent buffer fully unlocks playback on iOS Safari.
407
- const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
408
- const source = ctx.createBufferSource();
409
- source.buffer = buffer;
410
- source.connect(ctx.destination);
411
- source.start(0);
401
+ if (ctx) {
402
+ try {
403
+ if (ctx.state === 'suspended')
404
+ void ctx.resume();
405
+ // A 1-frame silent buffer fully unlocks playback on iOS Safari.
406
+ const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
407
+ const source = ctx.createBufferSource();
408
+ source.buffer = buffer;
409
+ source.connect(ctx.destination);
410
+ source.start(0);
411
+ }
412
+ catch {
413
+ /* best-effort unlock */
414
+ }
412
415
  }
413
- catch {
414
- /* best-effort unlock */
416
+ // Unlock device TTS (SpeechSynthesis) too. The spoken reply happens AFTER
417
+ // the network round-trip outside this gesture — and Safari/Chrome stay
418
+ // SILENT for a first programmatic speak that wasn't preceded by a
419
+ // gesture-initiated one. Warm the engine here: kick off voice loading and
420
+ // speak a zero-volume blank utterance inside the tap so the real reply is
421
+ // allowed to play later.
422
+ if (!options.disableDeviceTtsFallback && typeof window !== 'undefined' && window.speechSynthesis) {
423
+ try {
424
+ const synth = window.speechSynthesis;
425
+ synth.getVoices(); // triggers async voice loading on Chrome
426
+ if (synth.paused)
427
+ synth.resume();
428
+ const warm = new SpeechSynthesisUtterance(' ');
429
+ warm.volume = 0;
430
+ synth.speak(warm);
431
+ }
432
+ catch {
433
+ /* best-effort unlock */
434
+ }
415
435
  }
416
436
  },
417
437
  async playAudioBase64Mp3(base64) {
@@ -453,20 +473,32 @@ export function createWebVoiceAdapter(options = {}) {
453
473
  // Voices load lazily on Chrome — speaking before they exist is silent.
454
474
  const voices = await loadVoicesOnce(synth);
455
475
  return new Promise((resolve) => {
476
+ let keepAlive = null;
477
+ const cleanup = () => {
478
+ if (keepAlive) {
479
+ clearInterval(keepAlive);
480
+ keepAlive = null;
481
+ }
482
+ };
456
483
  try {
457
- // Clear any utterance the engine wedged on (a known Chrome failure
458
- // mode) and unpause before queuing the reply.
459
- if (synth.speaking || synth.pending)
460
- synth.cancel();
461
- if (synth.paused)
462
- synth.resume();
484
+ // Clear any previous/wedged queue. After a prior cancel() (e.g. the
485
+ // stopPlayback() fired when listening started) Chrome on macOS can sit
486
+ // in a stuck "paused" state that does NOT report paused===true, so a
487
+ // conditional resume() never fires and every later speak() is silent.
488
+ synth.cancel();
463
489
  const utter = new SpeechSynthesisUtterance(text);
464
490
  utter.lang = ttsOptions.locale;
465
491
  const voice = pickVoiceForLocale(voices, ttsOptions.locale);
466
492
  if (voice)
467
493
  utter.voice = voice;
494
+ utter.volume = 1;
495
+ utter.rate = 1;
496
+ utter.pitch = 1;
497
+ // Keep a strong ref so Chrome can't GC the utterance mid-speech.
468
498
  ttsUtterance = utter;
469
499
  const finish = () => {
500
+ cleanup();
501
+ utter.onstart = null;
470
502
  utter.onend = null;
471
503
  utter.onerror = null;
472
504
  if (ttsUtterance === utter)
@@ -476,8 +508,22 @@ export function createWebVoiceAdapter(options = {}) {
476
508
  utter.onend = finish;
477
509
  utter.onerror = finish;
478
510
  synth.speak(utter);
511
+ // THE FIX: resume() unconditionally right after queuing. This un-wedges
512
+ // the engine from the silent stuck-paused state described above.
513
+ synth.resume();
514
+ // Chrome desktop stops speaking after ~15s; pulse pause/resume to keep
515
+ // long replies going, and stop the timer once it has finished.
516
+ keepAlive = setInterval(() => {
517
+ if (!synth.speaking && !synth.pending) {
518
+ cleanup();
519
+ return;
520
+ }
521
+ synth.pause();
522
+ synth.resume();
523
+ }, 9000);
479
524
  }
480
525
  catch {
526
+ cleanup();
481
527
  resolve();
482
528
  }
483
529
  });
@@ -75,6 +75,7 @@ export declare class AgentController {
75
75
  private watchdog;
76
76
  private confirmWindow;
77
77
  private speakTimer;
78
+ private serverTtsUnavailable;
78
79
  private pendingConsentIntent;
79
80
  private readonly listeners;
80
81
  private unsubscribeSpeech;
@@ -15,7 +15,7 @@
15
15
  * deliberate modal tap; a loose "yes" never confirms them.
16
16
  * - The agent only ever executes actions declared in the manifest.
17
17
  */
18
- import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, createConversationSession, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
18
+ import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, createConversationSession, defaultRouteContextText, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
19
19
  import { AgentApiError } from '../api/errors.js';
20
20
  import { formatAgentTurnError } from '../api/formatTurnError.js';
21
21
  import { buildManifestPayload } from '../api/backendClient.js';
@@ -51,6 +51,9 @@ export class AgentController {
51
51
  // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
52
52
  // never reports completion (e.g. a stalled SpeechSynthesis engine).
53
53
  this.speakTimer = null;
54
+ // Once the backend shows it has no server-side TTS (a reply arrives with null
55
+ // audio), skip the extra /preview-tts round-trip and speak via device voice.
56
+ this.serverTtsUnavailable = false;
54
57
  this.pendingConsentIntent = null;
55
58
  this.listeners = new Set();
56
59
  this.unsubscribeSpeech = null;
@@ -87,7 +90,11 @@ export class AgentController {
87
90
  });
88
91
  this.screenStore = createScreenContextStore({
89
92
  getCurrentRoute: () => config.navigation.getCurrentRoute(),
90
- routeFallback: config.routeContextFallback,
93
+ // The integrator's fallback wins; otherwise the orb gets screen awareness
94
+ // out of the box from the matched manifest route (label + description +
95
+ // available actions). Null when no route matches → raw route, as today.
96
+ routeFallback: config.routeContextFallback ??
97
+ ((route) => defaultRouteContextText(route, config.manifest, this.messages)),
91
98
  });
92
99
  this.speech = createSpeechSession({
93
100
  adapter: config.voice,
@@ -194,13 +201,20 @@ export class AgentController {
194
201
  this.setPhase('speaking');
195
202
  try {
196
203
  let audio = audioBase64;
197
- if (!audio && text) {
198
- // A server-TTS failure must NOT skip the device-voice fallback below.
204
+ if (audio) {
205
+ this.serverTtsUnavailable = false; // server voice is working
206
+ }
207
+ // Once we know the backend ships no server TTS, skip the dead
208
+ // /preview-tts round-trip (pure latency) and use the device voice.
209
+ if (!audio && text && !this.serverTtsUnavailable) {
199
210
  try {
200
211
  audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
212
+ if (!audio)
213
+ this.serverTtsUnavailable = true;
201
214
  }
202
215
  catch {
203
216
  audio = null;
217
+ this.serverTtsUnavailable = true;
204
218
  }
205
219
  }
206
220
  const playback = audio
@@ -415,6 +429,10 @@ export class AgentController {
415
429
  }, { signal: controller.signal });
416
430
  reply = turn.reply;
417
431
  audioBase64 = turn.audioBase64;
432
+ // Learn whether this backend ships server-side TTS: a non-empty reply
433
+ // with no audio means it doesn't — later lines then speak instantly.
434
+ if (reply)
435
+ this.serverTtsUnavailable = !audioBase64;
418
436
  const decision = await decideAction({
419
437
  action: turn.action,
420
438
  manifest: this.config.manifest,
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.5";
8
+ export declare const SDK_VERSION = "0.1.7";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export const SDK_VERSION = '0.1.5';
8
+ export const SDK_VERSION = '0.1.7';
9
9
  export const SDK_PACKAGE = '@fiodos/web-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/web-core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Framework-agnostic browser layer for the Fiodos agent: a vanilla AgentController (orchestrator), DOM adapters (navigation/voice/storage), HTTP client and decision engine over @fiodos/core. Shared base for @fiodos/vue, @fiodos/svelte and @fiodos/angular (no framework imports).",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {