@wibly/sdk 0.1.2 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # `@wibly/sdk` — Changelog
2
2
 
3
+ ## 0.1.3 — 2026-06-08
4
+
5
+ ## 0.1.3 — 2026-06-08
6
+
7
+ ### Added
8
+
9
+ - `session.ttsPlayback` — client-side TTS playback telemetry
10
+ (`subscribe` / `getSnapshot`) exposing `{ isPlaying, speechLevel }`.
11
+ The Host shell samples speech energy from its single `<audio>` element
12
+ and pushes smoothed levels via `setTtsPlaybackTelemetry(session, …)` so
13
+ Experience bundles can drive amplitude-synced avatar animation without
14
+ high-frequency `voice.*` events on the wire.
15
+ - `@wibly/sdk/react` — `useTtsPlayback()` hook wrapping the telemetry
16
+ store for shell and bundle React trees.
17
+
3
18
  ## 0.1.2 — 2026-06-08
4
19
 
5
20
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wibly/sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Wibly @wibly/sdk",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,9 +18,9 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@wibly/internal-manifest": "0.1.2",
22
- "@wibly/internal-protocol": "0.1.2",
23
- "@wibly/internal-shared": "0.1.2",
21
+ "@wibly/internal-manifest": "0.1.3",
22
+ "@wibly/internal-protocol": "0.1.3",
23
+ "@wibly/internal-shared": "0.1.3",
24
24
  "zod": "^3.25.76"
25
25
  },
26
26
  "peerDependencies": {
package/src/client.ts CHANGED
@@ -77,6 +77,11 @@ import {
77
77
  createServerTimeSource,
78
78
  type ServerTimeSource,
79
79
  } from './time.js';
80
+ import {
81
+ createTtsPlaybackStore,
82
+ registerTtsPlaybackStore,
83
+ type TtsPlaybackTelemetry,
84
+ } from './tts-playback.js';
80
85
  import {
81
86
  createTransport,
82
87
  type Transport,
@@ -204,6 +209,13 @@ export type Session = {
204
209
  };
205
210
  readonly inference: SessionInference;
206
211
  readonly voice: SessionVoice;
212
+ /**
213
+ * Client-side TTS playback telemetry (speech energy + playing
214
+ * flag). Populated by the Host shell from the local audio element;
215
+ * read by Experience bundles to drive avatar animation. Never
216
+ * crosses the wire. See `tts-playback.ts`.
217
+ */
218
+ readonly ttsPlayback: TtsPlaybackTelemetry;
207
219
  readonly events: Pick<EventBus, 'onEvent' | 'onAnyEvent'>;
208
220
  readonly lifecycle: LifecycleBindings;
209
221
  /** Server-time helper. See `time.ts`. */
@@ -240,6 +252,7 @@ const DEFAULT_VOICE_SPEAK_TIMEOUT_MS = 30_000;
240
252
  export const createSession = (config: SessionConfig): Session => {
241
253
  const store = createSessionStore();
242
254
  const bus = createEventBus();
255
+ const ttsPlaybackStore = createTtsPlaybackStore();
243
256
  const time = createServerTimeSource({ now: config.now });
244
257
  const lifecycle = createLifecycleBindings(bus);
245
258
  let isPreviewFlag: boolean = config.isPreview ?? false;
@@ -806,6 +819,10 @@ export const createSession = (config: SessionConfig): Session => {
806
819
  scheduleTimer,
807
820
  voiceSpeakTimeoutMs,
808
821
  ),
822
+ ttsPlayback: {
823
+ subscribe: ttsPlaybackStore.subscribe,
824
+ getSnapshot: ttsPlaybackStore.getSnapshot,
825
+ },
809
826
  events: { onEvent: bus.onEvent, onAnyEvent: bus.onAnyEvent },
810
827
  lifecycle,
811
828
  time: { serverNow: time.serverNow, recordEvent: time.recordEvent },
@@ -836,6 +853,8 @@ export const createSession = (config: SessionConfig): Session => {
836
853
  },
837
854
  };
838
855
 
856
+ registerTtsPlaybackStore(session, ttsPlaybackStore);
857
+
839
858
  transport.start();
840
859
  return session;
841
860
  };
package/src/index.ts CHANGED
@@ -83,6 +83,15 @@ export {
83
83
  type VoiceAudioPayload,
84
84
  } from './voice.js';
85
85
 
86
+ export {
87
+ createTtsPlaybackStore,
88
+ registerTtsPlaybackStore,
89
+ setTtsPlaybackTelemetry,
90
+ type TtsPlaybackSnapshot,
91
+ type TtsPlaybackStore,
92
+ type TtsPlaybackTelemetry,
93
+ } from './tts-playback.js';
94
+
86
95
  export {
87
96
  type EventBus,
88
97
  type EventHandler,
package/src/react.ts CHANGED
@@ -37,6 +37,7 @@ import type { EventPayload, LifecyclePayload } from '@wibly/internal-protocol';
37
37
  import type { ConsentDecision, ConsentRequiredPayload } from './consent.js';
38
38
  import type { Session, SessionConfig, SessionState } from './client.js';
39
39
  import { createSession } from './client.js';
40
+ import type { TtsPlaybackSnapshot } from './tts-playback.js';
40
41
 
41
42
  const SessionContext = createContext<Session | null>(null);
42
43
 
@@ -121,6 +122,20 @@ export const useConnectionState = (): SessionState['connectionState'] => {
121
122
  return state.connectionState;
122
123
  };
123
124
 
125
+ /**
126
+ * Subscribe to client-side TTS playback telemetry (speech energy +
127
+ * playing flag). Populated by the Host shell's audio sampler; idle
128
+ * until the first clip plays.
129
+ */
130
+ export const useTtsPlayback = (): TtsPlaybackSnapshot => {
131
+ const session = useSession();
132
+ return useSyncExternalStore(
133
+ session.ttsPlayback.subscribe,
134
+ session.ttsPlayback.getSnapshot,
135
+ session.ttsPlayback.getSnapshot,
136
+ );
137
+ };
138
+
124
139
  /**
125
140
  * Subscribe to a server-emitted event by `eventType`. The handler
126
141
  * fires on every matching `event` frame. Unsubscribes on unmount.
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ createTtsPlaybackStore,
5
+ registerTtsPlaybackStore,
6
+ setTtsPlaybackTelemetry,
7
+ } from './tts-playback.js';
8
+
9
+ describe('createTtsPlaybackStore', () => {
10
+ it('starts idle', () => {
11
+ const store = createTtsPlaybackStore();
12
+ expect(store.getSnapshot()).toEqual({ isPlaying: false, speechLevel: 0 });
13
+ });
14
+
15
+ it('notifies subscribers when the snapshot changes', () => {
16
+ const store = createTtsPlaybackStore();
17
+ const listener = vi.fn();
18
+ const unsubscribe = store.subscribe(listener);
19
+
20
+ store.set({ isPlaying: true, speechLevel: 0.5 });
21
+
22
+ expect(listener).toHaveBeenCalledTimes(1);
23
+ expect(store.getSnapshot()).toEqual({ isPlaying: true, speechLevel: 0.5 });
24
+
25
+ unsubscribe();
26
+ store.set({ isPlaying: false, speechLevel: 0 });
27
+ expect(listener).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it('treats an identical set as a no-op and keeps a stable reference', () => {
31
+ const store = createTtsPlaybackStore();
32
+ const listener = vi.fn();
33
+ store.subscribe(listener);
34
+
35
+ const before = store.getSnapshot();
36
+ store.set({ isPlaying: false, speechLevel: 0 });
37
+
38
+ // useSyncExternalStore depends on a stable reference when nothing
39
+ // changed — otherwise it re-renders (or warns) every read.
40
+ expect(store.getSnapshot()).toBe(before);
41
+ expect(listener).not.toHaveBeenCalled();
42
+ });
43
+ });
44
+
45
+ describe('setTtsPlaybackTelemetry', () => {
46
+ it('routes a sample to the registered store', () => {
47
+ const session = {};
48
+ const store = createTtsPlaybackStore();
49
+ registerTtsPlaybackStore(session, store);
50
+
51
+ setTtsPlaybackTelemetry(session, { isPlaying: true, speechLevel: 0.8 });
52
+
53
+ expect(store.getSnapshot()).toEqual({ isPlaying: true, speechLevel: 0.8 });
54
+ });
55
+
56
+ it('is a no-op for a session with no registered store', () => {
57
+ expect(() =>
58
+ setTtsPlaybackTelemetry({}, { isPlaying: true, speechLevel: 1 }),
59
+ ).not.toThrow();
60
+ });
61
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Client-side TTS playback telemetry.
3
+ *
4
+ * This is a purely local, ephemeral signal — it never touches the
5
+ * WebSocket. The Host shell samples speech energy from the single
6
+ * `<audio>` element (Web Audio `AnalyserNode`) and pushes a smoothed
7
+ * `speechLevel` here at animation-frame cadence; Experience bundles
8
+ * read it through `session.ttsPlayback` to drive avatar animation
9
+ * (the amplitude-driven fallback the Platform Spec §3.5 describes for
10
+ * providers without phoneme metadata).
11
+ *
12
+ * Why a dedicated store and not a `voice.*` event: the signal updates
13
+ * ~60×/sec. Pumping that through `session.events` would flood every
14
+ * listener and the wire-shaped bus machinery. A tiny
15
+ * `useSyncExternalStore`-friendly snapshot store keeps the hot path
16
+ * cheap and matches the SDK's existing one-store-per-concern pattern.
17
+ *
18
+ * The read surface (`subscribe` / `getSnapshot`) is public on
19
+ * `Session.ttsPlayback`. The write surface lives behind
20
+ * `setTtsPlaybackTelemetry(session, snapshot)` so only the shell that
21
+ * owns the audio element mutates it — bundles consume, never produce.
22
+ */
23
+
24
+ export type TtsPlaybackSnapshot = {
25
+ /** True while a TTS clip is actively playing. */
26
+ readonly isPlaying: boolean;
27
+ /** Smoothed speech energy, normalised to 0..1. */
28
+ readonly speechLevel: number;
29
+ };
30
+
31
+ /** Read surface exposed on `Session.ttsPlayback`. */
32
+ export type TtsPlaybackTelemetry = {
33
+ readonly subscribe: (listener: () => void) => () => void;
34
+ readonly getSnapshot: () => TtsPlaybackSnapshot;
35
+ };
36
+
37
+ /** Full store — the read surface plus the shell-only setter. */
38
+ export type TtsPlaybackStore = TtsPlaybackTelemetry & {
39
+ readonly set: (next: TtsPlaybackSnapshot) => void;
40
+ };
41
+
42
+ const IDLE: TtsPlaybackSnapshot = { isPlaying: false, speechLevel: 0 };
43
+
44
+ export const createTtsPlaybackStore = (): TtsPlaybackStore => {
45
+ let snapshot: TtsPlaybackSnapshot = IDLE;
46
+ const listeners = new Set<() => void>();
47
+
48
+ return {
49
+ subscribe: (listener) => {
50
+ listeners.add(listener);
51
+ return () => {
52
+ listeners.delete(listener);
53
+ };
54
+ },
55
+ getSnapshot: () => snapshot,
56
+ set: (next) => {
57
+ if (
58
+ next.isPlaying === snapshot.isPlaying &&
59
+ next.speechLevel === snapshot.speechLevel
60
+ ) {
61
+ return;
62
+ }
63
+ snapshot = next;
64
+ for (const listener of Array.from(listeners)) {
65
+ try {
66
+ listener();
67
+ } catch {
68
+ // Listeners must not throw; swallow to avoid cascading.
69
+ }
70
+ }
71
+ },
72
+ };
73
+ };
74
+
75
+ /**
76
+ * Registry mapping a `Session` to its writable telemetry store. The
77
+ * `Session` only exposes the read surface, so the shell pushes
78
+ * samples through this side channel keyed by the session reference.
79
+ */
80
+ const storeRegistry = new WeakMap<object, TtsPlaybackStore>();
81
+
82
+ export const registerTtsPlaybackStore = (
83
+ session: object,
84
+ store: TtsPlaybackStore,
85
+ ): void => {
86
+ storeRegistry.set(session, store);
87
+ };
88
+
89
+ /**
90
+ * Push a telemetry sample for the given session. No-op if the session
91
+ * has no registered store (e.g. a stub session in a dev harness that
92
+ * supplies its own `ttsPlayback`).
93
+ */
94
+ export const setTtsPlaybackTelemetry = (
95
+ session: object,
96
+ snapshot: TtsPlaybackSnapshot,
97
+ ): void => {
98
+ storeRegistry.get(session)?.set(snapshot);
99
+ };