@fiodos/web-core 0.1.4 → 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.
@@ -2,6 +2,43 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createWebVoiceAdapter = createWebVoiceAdapter;
4
4
  const core_1 = require("@fiodos/core");
5
+ // ── Device TTS (SpeechSynthesis) helpers ────────────────────────────────────
6
+ // Chrome loads voices asynchronously: calling speak() before getVoices() is
7
+ // populated produces NO sound. Resolve the voice list once (event or timeout).
8
+ function loadVoicesOnce(synth) {
9
+ const ready = synth.getVoices();
10
+ if (ready.length)
11
+ return Promise.resolve(ready);
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const finish = () => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ resolve(synth.getVoices());
19
+ };
20
+ try {
21
+ synth.addEventListener('voiceschanged', finish, { once: true });
22
+ }
23
+ catch {
24
+ /* older engines: the timeout below is the only path */
25
+ }
26
+ // Some engines never emit `voiceschanged` — never block the reply forever.
27
+ setTimeout(finish, 600);
28
+ });
29
+ }
30
+ /** Best voice for a BCP-47 locale: exact → region-agnostic → language → default. */
31
+ function pickVoiceForLocale(voices, locale) {
32
+ if (!voices.length)
33
+ return undefined;
34
+ const lc = locale.toLowerCase();
35
+ const lang = lc.split('-')[0] ?? lc;
36
+ return (voices.find((v) => v.lang?.toLowerCase() === lc) ??
37
+ voices.find((v) => v.lang?.toLowerCase().startsWith(`${lang}-`)) ??
38
+ voices.find((v) => v.lang?.toLowerCase().startsWith(lang)) ??
39
+ voices.find((v) => v.default) ??
40
+ voices[0]);
41
+ }
5
42
  function getRecognitionCtor() {
6
43
  if (typeof window === 'undefined')
7
44
  return null;
@@ -33,6 +70,9 @@ function createWebVoiceAdapter(options = {}) {
33
70
  let aborted = false;
34
71
  let settled = false;
35
72
  let audioEl = null;
73
+ // Strong reference to the in-flight utterance: Chrome garbage-collects it
74
+ // otherwise, cutting device speech off mid-sentence (or producing silence).
75
+ let ttsUtterance = null;
36
76
  // ── Wake-word state (separate recognition instance; the browser allows only
37
77
  // one active session, so push-to-talk stops wake first). Desktop-only:
38
78
  // foreground tab while the page is visible — there is no passive background
@@ -361,20 +401,40 @@ function createWebVoiceAdapter(options = {}) {
361
401
  // later programmatic playback (after the network round-trip) is allowed
362
402
  // and audible even with the silent switch on.
363
403
  const ctx = ensureAudioCtx();
364
- if (!ctx)
365
- return;
366
- try {
367
- if (ctx.state === 'suspended')
368
- void ctx.resume();
369
- // A 1-frame silent buffer fully unlocks playback on iOS Safari.
370
- const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
371
- const source = ctx.createBufferSource();
372
- source.buffer = buffer;
373
- source.connect(ctx.destination);
374
- 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
+ }
375
418
  }
376
- catch {
377
- /* 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
+ }
378
438
  }
379
439
  },
380
440
  async playAudioBase64Mp3(base64) {
@@ -412,15 +472,61 @@ function createWebVoiceAdapter(options = {}) {
412
472
  return;
413
473
  if (typeof window === 'undefined' || !window.speechSynthesis)
414
474
  return;
475
+ const synth = window.speechSynthesis;
476
+ // Voices load lazily on Chrome — speaking before they exist is silent.
477
+ const voices = await loadVoicesOnce(synth);
415
478
  return new Promise((resolve) => {
479
+ let keepAlive = null;
480
+ const cleanup = () => {
481
+ if (keepAlive) {
482
+ clearInterval(keepAlive);
483
+ keepAlive = null;
484
+ }
485
+ };
416
486
  try {
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();
417
492
  const utter = new SpeechSynthesisUtterance(text);
418
493
  utter.lang = ttsOptions.locale;
419
- utter.onend = () => resolve();
420
- utter.onerror = () => resolve();
421
- window.speechSynthesis.speak(utter);
494
+ const voice = pickVoiceForLocale(voices, ttsOptions.locale);
495
+ if (voice)
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.
501
+ ttsUtterance = utter;
502
+ const finish = () => {
503
+ cleanup();
504
+ utter.onstart = null;
505
+ utter.onend = null;
506
+ utter.onerror = null;
507
+ if (ttsUtterance === utter)
508
+ ttsUtterance = null;
509
+ resolve();
510
+ };
511
+ utter.onend = finish;
512
+ utter.onerror = finish;
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);
422
527
  }
423
528
  catch {
529
+ cleanup();
424
530
  resolve();
425
531
  }
426
532
  });
@@ -74,6 +74,8 @@ export declare class AgentController {
74
74
  private abort;
75
75
  private watchdog;
76
76
  private confirmWindow;
77
+ private speakTimer;
78
+ private serverTtsUnavailable;
77
79
  private pendingConsentIntent;
78
80
  private readonly listeners;
79
81
  private unsubscribeSpeech;
@@ -95,7 +97,14 @@ export declare class AgentController {
95
97
  private setPhase;
96
98
  private clearWatchdog;
97
99
  private clearConfirmWindow;
100
+ private clearSpeakTimer;
98
101
  private speakReply;
102
+ /**
103
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
104
+ * completion (a stalled engine, a never-firing onend), stop it and continue
105
+ * after a generous, length-based cap so the orb returns to idle on its own.
106
+ */
107
+ private raceSpeakingWatchdog;
99
108
  private beginConfirmationVoiceWindow;
100
109
  confirmPendingAction(): Promise<void>;
101
110
  cancelPendingAction(): void;
@@ -51,6 +51,12 @@ class AgentController {
51
51
  this.abort = null;
52
52
  this.watchdog = null;
53
53
  this.confirmWindow = null;
54
+ // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
55
+ // never reports completion (e.g. a stalled SpeechSynthesis engine).
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;
54
60
  this.pendingConsentIntent = null;
55
61
  this.listeners = new Set();
56
62
  this.unsubscribeSpeech = null;
@@ -87,7 +93,11 @@ class AgentController {
87
93
  });
88
94
  this.screenStore = (0, screenContextStore_1.createScreenContextStore)({
89
95
  getCurrentRoute: () => config.navigation.getCurrentRoute(),
90
- 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)),
91
101
  });
92
102
  this.speech = (0, speechSession_1.createSpeechSession)({
93
103
  adapter: config.voice,
@@ -179,6 +189,12 @@ class AgentController {
179
189
  }
180
190
  this.confirmationVoiceActive = false;
181
191
  }
192
+ clearSpeakTimer() {
193
+ if (this.speakTimer) {
194
+ clearTimeout(this.speakTimer);
195
+ this.speakTimer = null;
196
+ }
197
+ }
182
198
  // ── Speech playback (ONE consistent voice) ─────────────────────────────────
183
199
  async speakReply(text, audioBase64) {
184
200
  if (this.silentTurn)
@@ -188,24 +204,62 @@ class AgentController {
188
204
  this.setPhase('speaking');
189
205
  try {
190
206
  let audio = audioBase64;
191
- if (!audio && text) {
192
- audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
193
- }
194
207
  if (audio) {
195
- await this.config.voice.playAudioBase64Mp3(audio);
208
+ this.serverTtsUnavailable = false; // server voice is working
196
209
  }
197
- else if (text) {
198
- await this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale });
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) {
213
+ try {
214
+ audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
215
+ if (!audio)
216
+ this.serverTtsUnavailable = true;
217
+ }
218
+ catch {
219
+ audio = null;
220
+ this.serverTtsUnavailable = true;
221
+ }
199
222
  }
223
+ const playback = audio
224
+ ? this.config.voice.playAudioBase64Mp3(audio)
225
+ : text
226
+ ? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
227
+ : Promise.resolve();
228
+ await this.raceSpeakingWatchdog(playback, text);
200
229
  }
201
230
  catch {
202
231
  /* playback best-effort */
203
232
  }
204
233
  finally {
234
+ this.clearSpeakTimer();
205
235
  if (this.phase === 'speaking')
206
236
  this.setPhase('idle');
207
237
  }
208
238
  }
239
+ /**
240
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
241
+ * completion (a stalled engine, a never-firing onend), stop it and continue
242
+ * after a generous, length-based cap so the orb returns to idle on its own.
243
+ */
244
+ raceSpeakingWatchdog(playback, text) {
245
+ const capMs = Math.min(60000, 5000 + text.length * 90);
246
+ return new Promise((resolve) => {
247
+ let settled = false;
248
+ const finish = () => {
249
+ if (settled)
250
+ return;
251
+ settled = true;
252
+ this.clearSpeakTimer();
253
+ resolve();
254
+ };
255
+ this.clearSpeakTimer();
256
+ this.speakTimer = setTimeout(() => {
257
+ this.config.voice.stopPlayback();
258
+ finish();
259
+ }, capMs);
260
+ playback.then(finish, finish);
261
+ });
262
+ }
209
263
  // ── Confirmation resolution ─────────────────────────────────────────────────
210
264
  beginConfirmationVoiceWindow() {
211
265
  const pending = this.pending;
@@ -378,6 +432,10 @@ class AgentController {
378
432
  }, { signal: controller.signal });
379
433
  reply = turn.reply;
380
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;
381
439
  const decision = await (0, turnEngine_1.decideAction)({
382
440
  action: turn.action,
383
441
  manifest: this.config.manifest,
@@ -544,8 +602,14 @@ class AgentController {
544
602
  this.setPhase('thinking');
545
603
  return;
546
604
  }
547
- if (this.phase === 'thinking' || this.phase === 'speaking')
605
+ if (this.phase === 'speaking') {
606
+ // Tap while the assistant is talking → stop the reply and reset, so the
607
+ // orb is immediately usable again (no more "stuck pulsing").
608
+ this.cancelAll();
548
609
  return;
610
+ }
611
+ if (this.phase === 'thinking')
612
+ return; // a turn is in flight; let it finish
549
613
  const consented = await this.consent.hasConsented();
550
614
  if (!consented) {
551
615
  this.pendingConsentIntent = 'voice';
@@ -597,6 +661,7 @@ class AgentController {
597
661
  this.abort = null;
598
662
  this.clearWatchdog();
599
663
  this.clearConfirmWindow();
664
+ this.clearSpeakTimer();
600
665
  this.speech.cancel();
601
666
  this.config.voice.stopPlayback();
602
667
  this.pending = null;
@@ -46,6 +46,7 @@ const CSS = `
46
46
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
47
47
  @keyframes fy-spin{to{transform:rotate(360deg)}}
48
48
  @keyframes fy-opulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.06);opacity:.85}}
49
+ @keyframes fy-voice{0%{transform:scale(0.6);opacity:.7}100%{transform:scale(1.35);opacity:0}}
49
50
  .fyodos-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(4,6,12,0.6);}
50
51
  .fyodos-modal{width:min(420px,100%);border-radius:16px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.5);}
51
52
  .fyodos-modal h3{margin:0 0 10px;font-size:17px;font-weight:600;}
@@ -215,6 +215,52 @@ function buildThinkingSpinner(orbSizePx, color) {
215
215
  wrap.appendChild(spin);
216
216
  return wrap;
217
217
  }
218
+ // ── Speaking pulse (port of the dashboard's no-logo "speaking" state) ─────────
219
+ // Two expanding rings + a pulsing centre dot — the default "talking" drawing the
220
+ // dashboard orb editor shows when there is no custom photo. Ratios are taken
221
+ // from the editor (118 / 30 / 2 px on its 380px reference orb) so the embedded
222
+ // orb matches it at any size.
223
+ const SPEAK_RING_RATIO = 118 / 380;
224
+ const SPEAK_DOT_RATIO = 30 / 380;
225
+ const SPEAK_BORDER_RATIO = 2 / 380;
226
+ function buildSpeakingPulse(orbSize, color) {
227
+ const wrap = document.createElement('div');
228
+ Object.assign(wrap.style, {
229
+ position: 'absolute',
230
+ inset: '0',
231
+ display: 'flex',
232
+ alignItems: 'center',
233
+ justifyContent: 'center',
234
+ pointerEvents: 'none',
235
+ });
236
+ const ringPx = orbSize * SPEAK_RING_RATIO;
237
+ const borderPx = Math.max(1, orbSize * SPEAK_BORDER_RATIO);
238
+ for (const delay of [0, 0.85]) {
239
+ const ring = document.createElement('div');
240
+ Object.assign(ring.style, {
241
+ position: 'absolute',
242
+ width: `${ringPx}px`,
243
+ height: `${ringPx}px`,
244
+ borderRadius: '50%',
245
+ border: `${borderPx}px solid ${color}`,
246
+ opacity: '0.55',
247
+ animation: `fy-voice 1.7s ease-out infinite ${delay}s`,
248
+ });
249
+ wrap.appendChild(ring);
250
+ }
251
+ const dotPx = orbSize * SPEAK_DOT_RATIO;
252
+ const dot = document.createElement('div');
253
+ Object.assign(dot.style, {
254
+ width: `${dotPx}px`,
255
+ height: `${dotPx}px`,
256
+ borderRadius: '50%',
257
+ background: color,
258
+ opacity: '0.9',
259
+ animation: 'fy-opulse 1s ease-in-out infinite',
260
+ });
261
+ wrap.appendChild(dot);
262
+ return wrap;
263
+ }
218
264
  // ── Keyboard chip icon (port of ChipIconSvg) ──────────────────────────────────
219
265
  function buildChipIcon(icon, size, color) {
220
266
  const spec = (0, core_1.chipIconSpec)(icon);
@@ -369,7 +415,10 @@ function createOrbVisual(initial) {
369
415
  waveform?.stop();
370
416
  waveform = null;
371
417
  fillEl.replaceChildren();
372
- wrapper.style.animation = state === 'speaking' ? 'fy-opulse 1.1s ease-in-out infinite' : '';
418
+ // The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
419
+ // rings + dot when there's no photo) to mirror the dashboard editor, so the
420
+ // orb no longer pulses as a whole.
421
+ wrapper.style.animation = '';
373
422
  if (state === 'thinking') {
374
423
  fillEl.appendChild(buildThinkingSpinner(size, waveColor));
375
424
  return;
@@ -397,10 +446,17 @@ function createOrbVisual(initial) {
397
446
  userSelect: 'none',
398
447
  webkitUserSelect: 'none',
399
448
  webkitUserDrag: 'none',
449
+ // While speaking, the photo gently pulses (matches the dashboard).
450
+ animation: state === 'speaking' ? 'fy-opulse 1s ease-in-out infinite' : '',
400
451
  });
401
452
  fillEl.appendChild(img);
402
453
  return;
403
454
  }
455
+ if (state === 'speaking') {
456
+ // No photo → the default "talking" drawing (expanding rings + centre dot).
457
+ fillEl.appendChild(buildSpeakingPulse(size, waveColor));
458
+ return;
459
+ }
404
460
  if (state === 'idle') {
405
461
  fillEl.appendChild(buildOrbMark(size));
406
462
  }
@@ -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.3";
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.3';
11
+ exports.SDK_VERSION = '0.1.7';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';
@@ -1,4 +1,41 @@
1
1
  import { detectWakePhrase } from '@fiodos/core';
2
+ // ── Device TTS (SpeechSynthesis) helpers ────────────────────────────────────
3
+ // Chrome loads voices asynchronously: calling speak() before getVoices() is
4
+ // populated produces NO sound. Resolve the voice list once (event or timeout).
5
+ function loadVoicesOnce(synth) {
6
+ const ready = synth.getVoices();
7
+ if (ready.length)
8
+ return Promise.resolve(ready);
9
+ return new Promise((resolve) => {
10
+ let settled = false;
11
+ const finish = () => {
12
+ if (settled)
13
+ return;
14
+ settled = true;
15
+ resolve(synth.getVoices());
16
+ };
17
+ try {
18
+ synth.addEventListener('voiceschanged', finish, { once: true });
19
+ }
20
+ catch {
21
+ /* older engines: the timeout below is the only path */
22
+ }
23
+ // Some engines never emit `voiceschanged` — never block the reply forever.
24
+ setTimeout(finish, 600);
25
+ });
26
+ }
27
+ /** Best voice for a BCP-47 locale: exact → region-agnostic → language → default. */
28
+ function pickVoiceForLocale(voices, locale) {
29
+ if (!voices.length)
30
+ return undefined;
31
+ const lc = locale.toLowerCase();
32
+ const lang = lc.split('-')[0] ?? lc;
33
+ return (voices.find((v) => v.lang?.toLowerCase() === lc) ??
34
+ voices.find((v) => v.lang?.toLowerCase().startsWith(`${lang}-`)) ??
35
+ voices.find((v) => v.lang?.toLowerCase().startsWith(lang)) ??
36
+ voices.find((v) => v.default) ??
37
+ voices[0]);
38
+ }
2
39
  function getRecognitionCtor() {
3
40
  if (typeof window === 'undefined')
4
41
  return null;
@@ -30,6 +67,9 @@ export function createWebVoiceAdapter(options = {}) {
30
67
  let aborted = false;
31
68
  let settled = false;
32
69
  let audioEl = null;
70
+ // Strong reference to the in-flight utterance: Chrome garbage-collects it
71
+ // otherwise, cutting device speech off mid-sentence (or producing silence).
72
+ let ttsUtterance = null;
33
73
  // ── Wake-word state (separate recognition instance; the browser allows only
34
74
  // one active session, so push-to-talk stops wake first). Desktop-only:
35
75
  // foreground tab while the page is visible — there is no passive background
@@ -358,20 +398,40 @@ export function createWebVoiceAdapter(options = {}) {
358
398
  // later programmatic playback (after the network round-trip) is allowed
359
399
  // and audible even with the silent switch on.
360
400
  const ctx = ensureAudioCtx();
361
- if (!ctx)
362
- return;
363
- try {
364
- if (ctx.state === 'suspended')
365
- void ctx.resume();
366
- // A 1-frame silent buffer fully unlocks playback on iOS Safari.
367
- const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
368
- const source = ctx.createBufferSource();
369
- source.buffer = buffer;
370
- source.connect(ctx.destination);
371
- 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
+ }
372
415
  }
373
- catch {
374
- /* 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
+ }
375
435
  }
376
436
  },
377
437
  async playAudioBase64Mp3(base64) {
@@ -409,15 +469,61 @@ export function createWebVoiceAdapter(options = {}) {
409
469
  return;
410
470
  if (typeof window === 'undefined' || !window.speechSynthesis)
411
471
  return;
472
+ const synth = window.speechSynthesis;
473
+ // Voices load lazily on Chrome — speaking before they exist is silent.
474
+ const voices = await loadVoicesOnce(synth);
412
475
  return new Promise((resolve) => {
476
+ let keepAlive = null;
477
+ const cleanup = () => {
478
+ if (keepAlive) {
479
+ clearInterval(keepAlive);
480
+ keepAlive = null;
481
+ }
482
+ };
413
483
  try {
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();
414
489
  const utter = new SpeechSynthesisUtterance(text);
415
490
  utter.lang = ttsOptions.locale;
416
- utter.onend = () => resolve();
417
- utter.onerror = () => resolve();
418
- window.speechSynthesis.speak(utter);
491
+ const voice = pickVoiceForLocale(voices, ttsOptions.locale);
492
+ if (voice)
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.
498
+ ttsUtterance = utter;
499
+ const finish = () => {
500
+ cleanup();
501
+ utter.onstart = null;
502
+ utter.onend = null;
503
+ utter.onerror = null;
504
+ if (ttsUtterance === utter)
505
+ ttsUtterance = null;
506
+ resolve();
507
+ };
508
+ utter.onend = finish;
509
+ utter.onerror = finish;
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);
419
524
  }
420
525
  catch {
526
+ cleanup();
421
527
  resolve();
422
528
  }
423
529
  });
@@ -74,6 +74,8 @@ export declare class AgentController {
74
74
  private abort;
75
75
  private watchdog;
76
76
  private confirmWindow;
77
+ private speakTimer;
78
+ private serverTtsUnavailable;
77
79
  private pendingConsentIntent;
78
80
  private readonly listeners;
79
81
  private unsubscribeSpeech;
@@ -95,7 +97,14 @@ export declare class AgentController {
95
97
  private setPhase;
96
98
  private clearWatchdog;
97
99
  private clearConfirmWindow;
100
+ private clearSpeakTimer;
98
101
  private speakReply;
102
+ /**
103
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
104
+ * completion (a stalled engine, a never-firing onend), stop it and continue
105
+ * after a generous, length-based cap so the orb returns to idle on its own.
106
+ */
107
+ private raceSpeakingWatchdog;
99
108
  private beginConfirmationVoiceWindow;
100
109
  confirmPendingAction(): Promise<void>;
101
110
  cancelPendingAction(): void;
@@ -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';
@@ -48,6 +48,12 @@ export class AgentController {
48
48
  this.abort = null;
49
49
  this.watchdog = null;
50
50
  this.confirmWindow = null;
51
+ // Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
52
+ // never reports completion (e.g. a stalled SpeechSynthesis engine).
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;
51
57
  this.pendingConsentIntent = null;
52
58
  this.listeners = new Set();
53
59
  this.unsubscribeSpeech = null;
@@ -84,7 +90,11 @@ export class AgentController {
84
90
  });
85
91
  this.screenStore = createScreenContextStore({
86
92
  getCurrentRoute: () => config.navigation.getCurrentRoute(),
87
- 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)),
88
98
  });
89
99
  this.speech = createSpeechSession({
90
100
  adapter: config.voice,
@@ -176,6 +186,12 @@ export class AgentController {
176
186
  }
177
187
  this.confirmationVoiceActive = false;
178
188
  }
189
+ clearSpeakTimer() {
190
+ if (this.speakTimer) {
191
+ clearTimeout(this.speakTimer);
192
+ this.speakTimer = null;
193
+ }
194
+ }
179
195
  // ── Speech playback (ONE consistent voice) ─────────────────────────────────
180
196
  async speakReply(text, audioBase64) {
181
197
  if (this.silentTurn)
@@ -185,24 +201,62 @@ export class AgentController {
185
201
  this.setPhase('speaking');
186
202
  try {
187
203
  let audio = audioBase64;
188
- if (!audio && text) {
189
- audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
190
- }
191
204
  if (audio) {
192
- await this.config.voice.playAudioBase64Mp3(audio);
205
+ this.serverTtsUnavailable = false; // server voice is working
193
206
  }
194
- else if (text) {
195
- await this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale });
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) {
210
+ try {
211
+ audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
212
+ if (!audio)
213
+ this.serverTtsUnavailable = true;
214
+ }
215
+ catch {
216
+ audio = null;
217
+ this.serverTtsUnavailable = true;
218
+ }
196
219
  }
220
+ const playback = audio
221
+ ? this.config.voice.playAudioBase64Mp3(audio)
222
+ : text
223
+ ? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
224
+ : Promise.resolve();
225
+ await this.raceSpeakingWatchdog(playback, text);
197
226
  }
198
227
  catch {
199
228
  /* playback best-effort */
200
229
  }
201
230
  finally {
231
+ this.clearSpeakTimer();
202
232
  if (this.phase === 'speaking')
203
233
  this.setPhase('idle');
204
234
  }
205
235
  }
236
+ /**
237
+ * Never let the 'speaking' phase hang. If playback/TTS never reports
238
+ * completion (a stalled engine, a never-firing onend), stop it and continue
239
+ * after a generous, length-based cap so the orb returns to idle on its own.
240
+ */
241
+ raceSpeakingWatchdog(playback, text) {
242
+ const capMs = Math.min(60000, 5000 + text.length * 90);
243
+ return new Promise((resolve) => {
244
+ let settled = false;
245
+ const finish = () => {
246
+ if (settled)
247
+ return;
248
+ settled = true;
249
+ this.clearSpeakTimer();
250
+ resolve();
251
+ };
252
+ this.clearSpeakTimer();
253
+ this.speakTimer = setTimeout(() => {
254
+ this.config.voice.stopPlayback();
255
+ finish();
256
+ }, capMs);
257
+ playback.then(finish, finish);
258
+ });
259
+ }
206
260
  // ── Confirmation resolution ─────────────────────────────────────────────────
207
261
  beginConfirmationVoiceWindow() {
208
262
  const pending = this.pending;
@@ -375,6 +429,10 @@ export class AgentController {
375
429
  }, { signal: controller.signal });
376
430
  reply = turn.reply;
377
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;
378
436
  const decision = await decideAction({
379
437
  action: turn.action,
380
438
  manifest: this.config.manifest,
@@ -541,8 +599,14 @@ export class AgentController {
541
599
  this.setPhase('thinking');
542
600
  return;
543
601
  }
544
- if (this.phase === 'thinking' || this.phase === 'speaking')
602
+ if (this.phase === 'speaking') {
603
+ // Tap while the assistant is talking → stop the reply and reset, so the
604
+ // orb is immediately usable again (no more "stuck pulsing").
605
+ this.cancelAll();
545
606
  return;
607
+ }
608
+ if (this.phase === 'thinking')
609
+ return; // a turn is in flight; let it finish
546
610
  const consented = await this.consent.hasConsented();
547
611
  if (!consented) {
548
612
  this.pendingConsentIntent = 'voice';
@@ -594,6 +658,7 @@ export class AgentController {
594
658
  this.abort = null;
595
659
  this.clearWatchdog();
596
660
  this.clearConfirmWindow();
661
+ this.clearSpeakTimer();
597
662
  this.speech.cancel();
598
663
  this.config.voice.stopPlayback();
599
664
  this.pending = null;
@@ -43,6 +43,7 @@ const CSS = `
43
43
  @keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
44
44
  @keyframes fy-spin{to{transform:rotate(360deg)}}
45
45
  @keyframes fy-opulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.06);opacity:.85}}
46
+ @keyframes fy-voice{0%{transform:scale(0.6);opacity:.7}100%{transform:scale(1.35);opacity:0}}
46
47
  .fyodos-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(4,6,12,0.6);}
47
48
  .fyodos-modal{width:min(420px,100%);border-radius:16px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.5);}
48
49
  .fyodos-modal h3{margin:0 0 10px;font-size:17px;font-weight:600;}
@@ -210,6 +210,52 @@ function buildThinkingSpinner(orbSizePx, color) {
210
210
  wrap.appendChild(spin);
211
211
  return wrap;
212
212
  }
213
+ // ── Speaking pulse (port of the dashboard's no-logo "speaking" state) ─────────
214
+ // Two expanding rings + a pulsing centre dot — the default "talking" drawing the
215
+ // dashboard orb editor shows when there is no custom photo. Ratios are taken
216
+ // from the editor (118 / 30 / 2 px on its 380px reference orb) so the embedded
217
+ // orb matches it at any size.
218
+ const SPEAK_RING_RATIO = 118 / 380;
219
+ const SPEAK_DOT_RATIO = 30 / 380;
220
+ const SPEAK_BORDER_RATIO = 2 / 380;
221
+ function buildSpeakingPulse(orbSize, color) {
222
+ const wrap = document.createElement('div');
223
+ Object.assign(wrap.style, {
224
+ position: 'absolute',
225
+ inset: '0',
226
+ display: 'flex',
227
+ alignItems: 'center',
228
+ justifyContent: 'center',
229
+ pointerEvents: 'none',
230
+ });
231
+ const ringPx = orbSize * SPEAK_RING_RATIO;
232
+ const borderPx = Math.max(1, orbSize * SPEAK_BORDER_RATIO);
233
+ for (const delay of [0, 0.85]) {
234
+ const ring = document.createElement('div');
235
+ Object.assign(ring.style, {
236
+ position: 'absolute',
237
+ width: `${ringPx}px`,
238
+ height: `${ringPx}px`,
239
+ borderRadius: '50%',
240
+ border: `${borderPx}px solid ${color}`,
241
+ opacity: '0.55',
242
+ animation: `fy-voice 1.7s ease-out infinite ${delay}s`,
243
+ });
244
+ wrap.appendChild(ring);
245
+ }
246
+ const dotPx = orbSize * SPEAK_DOT_RATIO;
247
+ const dot = document.createElement('div');
248
+ Object.assign(dot.style, {
249
+ width: `${dotPx}px`,
250
+ height: `${dotPx}px`,
251
+ borderRadius: '50%',
252
+ background: color,
253
+ opacity: '0.9',
254
+ animation: 'fy-opulse 1s ease-in-out infinite',
255
+ });
256
+ wrap.appendChild(dot);
257
+ return wrap;
258
+ }
213
259
  // ── Keyboard chip icon (port of ChipIconSvg) ──────────────────────────────────
214
260
  function buildChipIcon(icon, size, color) {
215
261
  const spec = chipIconSpec(icon);
@@ -364,7 +410,10 @@ export function createOrbVisual(initial) {
364
410
  waveform?.stop();
365
411
  waveform = null;
366
412
  fillEl.replaceChildren();
367
- wrapper.style.animation = state === 'speaking' ? 'fy-opulse 1.1s ease-in-out infinite' : '';
413
+ // The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
414
+ // rings + dot when there's no photo) to mirror the dashboard editor, so the
415
+ // orb no longer pulses as a whole.
416
+ wrapper.style.animation = '';
368
417
  if (state === 'thinking') {
369
418
  fillEl.appendChild(buildThinkingSpinner(size, waveColor));
370
419
  return;
@@ -392,10 +441,17 @@ export function createOrbVisual(initial) {
392
441
  userSelect: 'none',
393
442
  webkitUserSelect: 'none',
394
443
  webkitUserDrag: 'none',
444
+ // While speaking, the photo gently pulses (matches the dashboard).
445
+ animation: state === 'speaking' ? 'fy-opulse 1s ease-in-out infinite' : '',
395
446
  });
396
447
  fillEl.appendChild(img);
397
448
  return;
398
449
  }
450
+ if (state === 'speaking') {
451
+ // No photo → the default "talking" drawing (expanding rings + centre dot).
452
+ fillEl.appendChild(buildSpeakingPulse(size, waveColor));
453
+ return;
454
+ }
399
455
  if (state === 'idle') {
400
456
  fillEl.appendChild(buildOrbMark(size));
401
457
  }
@@ -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.3";
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.3';
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.4",
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": {
@@ -25,7 +25,8 @@
25
25
  "scripts": {
26
26
  "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node ../../scripts/fix-esm-extensions.mjs dist/esm && node ../../scripts/mark-dual.mjs",
27
27
  "typecheck": "tsc -p tsconfig.json --noEmit",
28
- "test": "node --import tsx --test test/*.test.ts"
28
+ "test": "node --import tsx --test test/*.test.ts",
29
+ "prepublishOnly": "node ../../scripts/sync-sdk-version.mjs && npm run build"
29
30
  },
30
31
  "dependencies": {
31
32
  "@fiodos/core": "^0.1.0"