@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 +15 -0
- package/package.json +4 -4
- package/src/client.ts +19 -0
- package/src/index.ts +9 -0
- package/src/react.ts +15 -0
- package/src/tts-playback.test.ts +61 -0
- package/src/tts-playback.ts +99 -0
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.
|
|
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.
|
|
22
|
-
"@wibly/internal-protocol": "0.1.
|
|
23
|
-
"@wibly/internal-shared": "0.1.
|
|
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
|
+
};
|