@graineai/inapp-react-native 0.40.0 → 0.40.1
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/INTEGRATION.md +113 -113
- package/README.md +13 -5
- package/dist/client.d.ts +6 -0
- package/dist/client.js +52 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol.d.ts +1 -0
- package/dist/protocol.js +11 -0
- package/dist/react-native/index.d.ts +1 -0
- package/dist/react-native/index.js +2 -1
- package/dist/react-native/voice-launcher.js +6 -3
- package/dist/voice.d.ts +2 -0
- package/dist/voice.js +6 -2
- package/package.json +1 -1
package/INTEGRATION.md
CHANGED
|
@@ -49,7 +49,7 @@ carried differently, and the SDK is what hides that from you.
|
|
|
49
49
|
│ hidden WebView (1×1) │ ┌──────────────┐
|
|
50
50
|
│ the audio engine │══ media ══▶│ the agent │
|
|
51
51
|
│ capture · playback │ │ │
|
|
52
|
-
│ jitter buffer · barge-in│──control ─▶│
|
|
52
|
+
│ jitter buffer · barge-in│──control ─▶│ (Graine) │
|
|
53
53
|
└─────────────────────────┘ └──────┬───────┘
|
|
54
54
|
│
|
|
55
55
|
▲ │
|
|
@@ -62,7 +62,7 @@ carried differently, and the SDK is what hides that from you.
|
|
|
62
62
|
native audio module, which means a store release for every audio fix — and the
|
|
63
63
|
audio path is exactly where the fixes are. The page is one pixel, invisible and
|
|
64
64
|
unreachable; your customer only ever sees your own UI. See *Why there is no
|
|
65
|
-
|
|
65
|
+
native setup* at the end.
|
|
66
66
|
|
|
67
67
|
### What travels, and when
|
|
68
68
|
|
|
@@ -362,11 +362,11 @@ may do in your app". The name must match exactly. Saving pushes it to the agent
|
|
|
362
362
|
immediately.
|
|
363
363
|
|
|
364
364
|
Neither half works alone. A handler with no declaration is dead code; a
|
|
365
|
-
declaration with no handler is worse, because the agent will promise it and
|
|
366
|
-
|
|
365
|
+
declaration with no handler is worse, because the agent will promise it and then
|
|
366
|
+
have to say "this app build does not implement it".
|
|
367
367
|
|
|
368
368
|
Return a message saying what actually happened — the agent repeats it, so a vague
|
|
369
|
-
result becomes a vague promise. **The
|
|
369
|
+
result becomes a vague promise. **The agent waits for your handler**, so return
|
|
370
370
|
promptly; anything slow should return immediately and report completion by
|
|
371
371
|
updating the screen.
|
|
372
372
|
|
|
@@ -413,7 +413,7 @@ you wrote `[]`.)
|
|
|
413
413
|
|
|
414
414
|
**A handler has six seconds.** `ACTION_DEADLINE_MS`. Return promptly — do
|
|
415
415
|
the slow part after you have answered. A handler that has not resolved by
|
|
416
|
-
then is answered as a timeout
|
|
416
|
+
then is answered as a timeout, and the agent tells the
|
|
417
417
|
customer it was *not* done rather than guessing. A handler that throws is
|
|
418
418
|
answered as `error` for the same reason.
|
|
419
419
|
|
|
@@ -510,7 +510,7 @@ explains a conversation: a declined payment, a rejected document, an OTP retried
|
|
|
510
510
|
three times.
|
|
511
511
|
|
|
512
512
|
The last 10 are kept. To make the agent speak first, report an event instead —
|
|
513
|
-
and the
|
|
513
|
+
and the agent decides whether it is worth interrupting for.
|
|
514
514
|
|
|
515
515
|
---
|
|
516
516
|
|
|
@@ -532,7 +532,7 @@ Reserve it for a dead end the customer would want addressed without asking: a
|
|
|
532
532
|
save that never reached the server, an upload that failed twice, a payment
|
|
533
533
|
declined. Not a tap, not a navigation, not a save that worked.
|
|
534
534
|
|
|
535
|
-
The
|
|
535
|
+
The agent still decides whether to speak: silent while it is talking, while a
|
|
536
536
|
reply is generating, for 20s after the last intervention, and after three in a
|
|
537
537
|
session.
|
|
538
538
|
|
|
@@ -641,7 +641,7 @@ export default function App() {
|
|
|
641
641
|
```
|
|
642
642
|
|
|
643
643
|
Taps land in the screen frame as `tap:screen` events with the tap's position;
|
|
644
|
-
the
|
|
644
|
+
the agent reads them as "tapped the screen" and Call History shows them on the
|
|
645
645
|
call's timeline. Named taps from `useGraineTap` are unaffected and still carry
|
|
646
646
|
their names. (0.27.4 — earlier versions did not export `GraineTouchCapture`.)
|
|
647
647
|
|
|
@@ -791,8 +791,13 @@ call that asked for it.
|
|
|
791
791
|
<GraineProvider … endOnBackground={false}> {/* default: true */}
|
|
792
792
|
```
|
|
793
793
|
|
|
794
|
-
|
|
795
|
-
|
|
794
|
+
It governs the call as well as the chat — the voice launcher honours it from
|
|
795
|
+
**0.40.1**; before, a call ended two seconds after the app left the foreground
|
|
796
|
+
whatever the provider said. Turn it off only when your app keeps the call alive
|
|
797
|
+
itself: a call screen of its own and, on Android, a microphone foreground
|
|
798
|
+
service. On iOS, test on real devices first — whether a web view keeps its
|
|
799
|
+
microphone in the background varies between iOS versions. See *A call like
|
|
800
|
+
WhatsApp* below.
|
|
796
801
|
|
|
797
802
|
Text sessions reconnect when the app returns. **Voice does not auto-resume** — a
|
|
798
803
|
call is something a person chose to start, and starting one again because they
|
|
@@ -800,9 +805,8 @@ came back to the app is a microphone opening unasked. `connected` goes false;
|
|
|
800
805
|
draw from that.
|
|
801
806
|
|
|
802
807
|
**`connect()` is idempotent.** Calling it twice returns the same session rather
|
|
803
|
-
than opening a second
|
|
804
|
-
mounts effects twice
|
|
805
|
-
to close it, and the runtime kept it open and billing until its own timeout.
|
|
808
|
+
than opening a second one — which matters under React 18 strict mode, which
|
|
809
|
+
mounts effects twice.
|
|
806
810
|
|
|
807
811
|
**Nothing is left holding the call.** An outstanding action's waiter is released
|
|
808
812
|
in a `finally`, so a customer closing the app mid-action releases it too, and
|
|
@@ -816,38 +820,74 @@ for you on unmount and on backgrounding.
|
|
|
816
820
|
|
|
817
821
|
---
|
|
818
822
|
|
|
823
|
+
## A call like WhatsApp
|
|
824
|
+
|
|
825
|
+
A call that rings the customer's phone — CallKit and VoIP pushes on iOS, a
|
|
826
|
+
ringing call notification and call screen on Android — and goes on with the
|
|
827
|
+
screen off runs on Graine's native call engine, in its own package:
|
|
828
|
+
`@graineai/inapp-react-native-calls`. It carries native code, so it needs a new
|
|
829
|
+
store build; this SDK stays over-the-air.
|
|
830
|
+
|
|
831
|
+
```tsx
|
|
832
|
+
await GraineCalls.configure({ publishableKey: "pk_live_…", displayName: "Acme Assistant" });
|
|
833
|
+
GraineCalls.enableVoIPPushes((token) => myServer.saveVoipToken(token)); // iOS
|
|
834
|
+
// Android: GraineCalls.handlePush(message.data) first in your Firebase handlers
|
|
835
|
+
await GraineCalls.startCall({ reason: "card_blocked" }); // the customer calls the agent
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
**Attach this SDK's client** inside `GraineProvider` and the call gets the same
|
|
839
|
+
screen context, identity, variables and actions as the assistant bar:
|
|
840
|
+
|
|
841
|
+
```tsx
|
|
842
|
+
function CallsBridge() {
|
|
843
|
+
const client = useGraineClient();
|
|
844
|
+
useEffect(() => GraineCalls.attach(client), [client]);
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
**Two prompt variables carry the reason**: `who_called` (`agent` or
|
|
850
|
+
`customer`) and `call_reason`, set by the engine on every call; give both a
|
|
851
|
+
default under Embed & Widgets → Variables. The pushes, the iOS capabilities and
|
|
852
|
+
your server's side are at https://www.graine.ai/docs/in-app/calling#react-native.
|
|
853
|
+
|
|
854
|
+
**Without a native module** (an Expo Go build, a release shipped only over the
|
|
855
|
+
air), a call can still run on this SDK's in-app engine: the launcher on a call
|
|
856
|
+
screen of your own, with `endOnBackground={false}` on the provider (honoured
|
|
857
|
+
for calls from 0.40.1). The ring is then a notification of your own — not
|
|
858
|
+
CallKit, because the engine's microphone runs in WebKit — and Android needs a
|
|
859
|
+
microphone foreground service for the length of the call. On iOS, whether a web
|
|
860
|
+
view keeps its microphone in the background varies between iOS versions: test
|
|
861
|
+
it on the versions you support.
|
|
862
|
+
|
|
863
|
+
---
|
|
864
|
+
|
|
819
865
|
## How long "Connecting…" takes, and why
|
|
820
866
|
|
|
821
|
-
From the tap to the agent's first word
|
|
822
|
-
are
|
|
823
|
-
|
|
824
|
-
|
|
867
|
+
From the tap to the agent's first word, four things happen. On a current SDK,
|
|
868
|
+
three of them are done before the tap. If the launcher sits on "Connecting…"
|
|
869
|
+
for more than about two seconds, one of them is not happening, and the table
|
|
870
|
+
says which.
|
|
825
871
|
|
|
826
|
-
|
|
|
872
|
+
| Step | What it is | When it happens | Typical |
|
|
827
873
|
|---|---|---|---|
|
|
828
|
-
|
|
|
829
|
-
|
|
|
830
|
-
|
|
|
831
|
-
|
|
|
832
|
-
|
|
833
|
-
**
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
**On an app that is still on 0.26.x** the first two rows are paid *after* the
|
|
847
|
-
tap: the page loads and registers only once the customer has pressed the
|
|
848
|
-
microphone, so "Connecting…" is honest and about six seconds long. Moving to
|
|
849
|
-
0.27.4 is what hides them. The dial bound needs no app change — it lives in
|
|
850
|
-
the engine page, so every install gets it when the page is deployed.
|
|
874
|
+
| The engine loads | The voice engine is a page in a WebView | **Before the tap** — `GraineFab` loads it when it opens; a custom bar mounts `GraineVoiceLauncher` at launch with `active={false}` and turns it on when it opens (0.27.5) | ~4 s, out of sight |
|
|
875
|
+
| The line is prepared | A short-lived connection to Graine, made ready | **Before the tap**, renewed before it expires and whenever the app returns to the foreground (0.36.0) | ~1 s, out of sight |
|
|
876
|
+
| The microphone | The OS permission prompt, first time only | At the tap | none once granted |
|
|
877
|
+
| The call connects | The call itself, on a network route found before the tap | At the tap | ~0.2 s |
|
|
878
|
+
|
|
879
|
+
**Connecting never waits on a network route it does not need.** A device can
|
|
880
|
+
have interfaces that never answer — a VPN tunnel, a link-local IPv6 address, a
|
|
881
|
+
cellular path the app may not use — and trying every one of them can take forty
|
|
882
|
+
seconds. The engine connects on the first route that works, or after 1.5
|
|
883
|
+
seconds with the routes it has, so a phone on Wi-Fi and cellular connects in
|
|
884
|
+
about a second rather than ten.
|
|
885
|
+
|
|
886
|
+
**On an app that is still on 0.26.x** the first two steps happen *after* the
|
|
887
|
+
tap: the page loads and prepares the line only once the customer has pressed
|
|
888
|
+
the microphone, so "Connecting…" is honest and about six seconds long. Moving
|
|
889
|
+
to 0.27.4 is what hides them. The route bound needs no app change — it lives in
|
|
890
|
+
the engine page, so every install already has it.
|
|
851
891
|
|
|
852
892
|
What "Connecting…" should look like on a current app, then: a tap, the
|
|
853
893
|
microphone prompt once, and the greeting inside a second.
|
|
@@ -896,12 +936,12 @@ Pass it before you debug anything else.
|
|
|
896
936
|
| `reason` | Means | Retry helps? |
|
|
897
937
|
|---|---|---|
|
|
898
938
|
| `not_configured` | A setting is wrong — see below. Also raised through `onError`. | Never |
|
|
899
|
-
| `unreachable` |
|
|
939
|
+
| `unreachable` | WebRTC is not set up for your organisation, or Graine's media servers cannot be reached from this network. | Sometimes |
|
|
900
940
|
| `screen_channel` | Audio connected, the screen channel did not, so the call restarted rather than run blind. | Sometimes |
|
|
901
941
|
|
|
902
942
|
**`not_configured`: which setting?** The call still works — it is carried on the
|
|
903
943
|
WebSocket — so nothing is on fire, but WebRTC will never be used until this is
|
|
904
|
-
fixed. Ask the
|
|
944
|
+
fixed. Ask the WebRTC endpoint directly, without rebuilding anything:
|
|
905
945
|
|
|
906
946
|
```bash
|
|
907
947
|
curl -i -X POST https://www.graine.ai/api/embed/rtc-session \
|
|
@@ -917,8 +957,8 @@ curl -i -X POST https://www.graine.ai/api/embed/rtc-session \
|
|
|
917
957
|
| 403 | `no_origin` | **The common one for apps.** A native app sends no `Origin`, so add the literal entry `app://` to that agent's Allowed domains. |
|
|
918
958
|
| 403 | `no_domains_configured` | The allowlist is empty, which denies everything. |
|
|
919
959
|
| 403 | `origin_not_allowed` | Add your site to Allowed domains. |
|
|
920
|
-
| 503 | `not_configured` | WebRTC is not enabled
|
|
921
|
-
| 200 | — |
|
|
960
|
+
| 503 | `not_configured` | WebRTC is not enabled for your organisation. Ask Graine support — it is not fixable from the app. |
|
|
961
|
+
| 200 | — | Your key and Allowed domains are fine; look at `onTransport` for `unreachable` instead. |
|
|
922
962
|
|
|
923
963
|
That curl sends **no** `Origin` header, exactly as a native app does — so a 403
|
|
924
964
|
from it is precisely the 403 your app is getting.
|
|
@@ -985,9 +1025,9 @@ A customer who taps through four screens and then asks "where am I?" gets the
|
|
|
985
1025
|
fourth. The guarantees, so you do not have to think about them:
|
|
986
1026
|
|
|
987
1027
|
- **Immediate.** `useGraineScreen` reports a screen the moment it mounts, and
|
|
988
|
-
the voice launcher
|
|
989
|
-
|
|
990
|
-
- **Ordered.** Every context frame carries a monotonic `seq`; the
|
|
1028
|
+
the voice launcher passes it on at once — the 400ms coalescing applies only
|
|
1029
|
+
to the SDK's own chat connection, never to a host's channel.
|
|
1030
|
+
- **Ordered.** Every context frame carries a monotonic `seq`; the agent keeps
|
|
991
1031
|
the newest and drops anything that arrives late. The last screen always wins.
|
|
992
1032
|
- **Cleanup-safe.** The description is re-sent whenever its state changes and
|
|
993
1033
|
withdrawn only on unmount — never on a change (0.26.2; earlier versions sent
|
|
@@ -1001,9 +1041,9 @@ fourth. The guarantees, so you do not have to think about them:
|
|
|
1001
1041
|
a call and when the app returns to the foreground, with its original
|
|
1002
1042
|
sequence number — so a frame lost in flight is repaired within seconds, not
|
|
1003
1043
|
at the next screen change; one that arrived is simply ignored.
|
|
1004
|
-
- **Applied every turn.** The
|
|
1005
|
-
|
|
1006
|
-
|
|
1044
|
+
- **Applied every turn.** The agent is given the last-reported screen on every
|
|
1045
|
+
turn, so nothing that changes the conversation mid-call — a language switch,
|
|
1046
|
+
a summary — can make it forget where the customer is.
|
|
1007
1047
|
|
|
1008
1048
|
Every reporting hook works **outside a provider** too — against the client the
|
|
1009
1049
|
provider registered, or one you pass to `setDefaultClient(client)` once — so an
|
|
@@ -1033,48 +1073,12 @@ screens already report.
|
|
|
1033
1073
|
## Captions on WebRTC
|
|
1034
1074
|
|
|
1035
1075
|
Captions — the agent's line and what the customer is saying — reach the host
|
|
1036
|
-
on both transports. On WebRTC the
|
|
1037
|
-
|
|
1076
|
+
on both transports. On WebRTC the captions arrive on a channel of their own; a
|
|
1077
|
+
native bar using
|
|
1038
1078
|
`onCaption` sees `role: "customer"` and `role: "agent"` lines either way.
|
|
1039
1079
|
|
|
1040
1080
|
---
|
|
1041
1081
|
|
|
1042
|
-
## Coming from RevRag
|
|
1043
|
-
|
|
1044
|
-
Same shape, different names. If you have a RevRag integration, this is the
|
|
1045
|
-
whole translation:
|
|
1046
|
-
|
|
1047
|
-
| RevRag | Graine |
|
|
1048
|
-
| --- | --- |
|
|
1049
|
-
| `useInitialize({ apiKey })` → `{ isInitialized, error }` | `useGraineReady()` → `{ ready, error }` (the key goes on `GraineProvider`) |
|
|
1050
|
-
| `EmbedProvider navigationRef appVersion` | `GraineProvider navigationRef appVersion` |
|
|
1051
|
-
| `includeScreens` | `includeScreens` |
|
|
1052
|
-
| `embedButtonDelayMs` | `launcherDelayMs` |
|
|
1053
|
-
| `embedButtonVisibilityConfig` `{ defaultDelayMs, defaultInset, groups }` | `visibility` `{ defaultDelayMs, defaultInset, groups }` |
|
|
1054
|
-
| `EmbedButtonGroupConfig` `{ id, screens, continuity, delayMs, delayPolicy, inset }` | `LauncherGroup` — identical fields |
|
|
1055
|
-
| `continuous` / `perScreen` | same |
|
|
1056
|
-
| `perScreen` / `oncePerGroupEntry` / `oncePerAppSession` | same |
|
|
1057
|
-
| `EmbedButtonInset` | `LauncherInset` — same shape |
|
|
1058
|
-
| `EmbedButton` / the provider mounts the FAB | `fab={{ webView }}` on `GraineProvider`, or `GraineFab` yourself |
|
|
1059
|
-
| Best-effort click tracking on touchables | `captureTaps` (on by default with `fab`) — `tap:screen` events |
|
|
1060
|
-
| `useInitialize` → `{ isInitialized, error }` | `useGraineInit()` |
|
|
1061
|
-
| `Embed.Event(USER_DATA, { app_user_id, data })` | `useGraineIdentify()` / `client.identify({ id, name, … })` |
|
|
1062
|
-
| `Embed.Event(SCREEN_STATE, { screen, data })` | `useGraineScreen({ screen, fields })` |
|
|
1063
|
-
| `Embed.Event(CUSTOM_EVENT, data)` / `ANALYTICS_DATA` | `useGraineTrack()` / `client.track(name, data)` |
|
|
1064
|
-
| `embedOnAgent(AGENT_CONVERSATION_STARTED / ENDED)` | `useGraineEvents(e => e.type === "conversation_started" / "conversation_ended")` |
|
|
1065
|
-
| `AgentEvent.MICROPHONE_PERMISSION_DENIED` | `{ type: "mic_denied", reason }` on the same stream |
|
|
1066
|
-
| `AgentEvent.POPUP_MESSAGE_VISIBLE` | `{ type: "launcher_shown", screen }` |
|
|
1067
|
-
| `checkPermissions()` | `requestMicrophonePermission()` |
|
|
1068
|
-
| server `widget_config` | `appearance` from the session, via `resolveNativeAppearance` |
|
|
1069
|
-
| LiveKit native setup | none — see *Why there is no LiveKit step* |
|
|
1070
|
-
| Friction detection (dashboard switch) | Per-screen JSON rules — `friction` on the provider, or Embed & Widgets → Friction; five named signals, `friction_detected` event |
|
|
1071
|
-
|
|
1072
|
-
Two things RevRag has that are deliberately absent: a LiveKit install step
|
|
1073
|
-
(audio rides a WebView on a hosted page, so audio fixes ship without an app
|
|
1074
|
-
release), and click tracking on every touchable by default (`useGraineTap` is
|
|
1075
|
-
opt-in per element, because a stream of every tap is noise the agent has to be
|
|
1076
|
-
told to ignore).
|
|
1077
|
-
|
|
1078
1082
|
## Reference
|
|
1079
1083
|
|
|
1080
1084
|
| Hook | For |
|
|
@@ -1104,7 +1108,7 @@ told to ignore).
|
|
|
1104
1108
|
| `visibility` | `LauncherVisibility` | Groups, continuity, per-group delays and insets. See *Controlling where the launcher appears*. |
|
|
1105
1109
|
| `autoConnect` | `boolean` | Default `true`. Set `false` when `GraineVoiceLauncher` owns the connection — see below. |
|
|
1106
1110
|
| `onProactive` | `(text) => void` | The agent spoke first; draw your own nudge. |
|
|
1107
|
-
| `endOnBackground` | `boolean` | Default `true
|
|
1111
|
+
| `endOnBackground` | `boolean` | Default `true`: the session — and, from 0.40.1, the call — ends two seconds after the app leaves the foreground. Turn it off only when your app keeps the call alive itself; see *A call like WhatsApp*. |
|
|
1108
1112
|
| `appVersion` | `string` | Which build the conversation happened in. Lands on the call record. |
|
|
1109
1113
|
| `friction` | `FrictionConfig` | This app's friction rules, layered over the dashboard's. See *Friction detection*. |
|
|
1110
1114
|
| `loadFont` | `(fonts) => Promise` | expo-font's `Font.loadAsync`. The SDK loads the design's typeface itself — only the faces it uses, from Graine. **0.38.0** |
|
|
@@ -1137,8 +1141,8 @@ told to ignore).
|
|
|
1137
1141
|
| `onEnded` | `() => void` | The call is over. Fires once. Give the goodbye ~400ms to play before you unmount. |
|
|
1138
1142
|
| `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
|
|
1139
1143
|
| `onError` | `(message) => void` | Voice could not be set up at all — key rejected, agent not enabled for apps, no network. Without this the launcher renders nothing and you cannot tell why. |
|
|
1140
|
-
| `children` | `(api) => ReactNode` | Receives `
|
|
1141
|
-
| `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it
|
|
1144
|
+
| `children` | `(api) => ReactNode` | Receives the voice API: `start`, `end`, `setMuted`, `connected`, `connecting`, `connectStage`, `slowConnect`, `callFailed`, `captions`, `quality`, `reconnecting`, `micDenied`, `error` and more — draw your own call from it (*Captions and hanging up*). |
|
|
1145
|
+
| `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it connects nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it prepares the line so the tap is instant. Back to `false` ends a live call. (0.27.5; 0.27.6 makes that hangup unconditional) |
|
|
1142
1146
|
|
|
1143
1147
|
**The launcher owns the connection.** It asks for the microphone, resolves the
|
|
1144
1148
|
session, holds the socket and relays your screen, actions and events onto it. So
|
|
@@ -1154,20 +1158,20 @@ changed without shipping a release. Passing the prop overrides it.
|
|
|
1154
1158
|
`ws` (the default) sends raw PCM on the same socket that carries screen context
|
|
1155
1159
|
and in-app actions.
|
|
1156
1160
|
|
|
1157
|
-
`rtc`
|
|
1158
|
-
and the platform's own echo canceller
|
|
1159
|
-
|
|
1161
|
+
`rtc` sends audio over WebRTC to Graine's media servers — Opus, encrypted
|
|
1162
|
+
(DTLS-SRTP), an adaptive jitter buffer, and the platform's own echo canceller.
|
|
1163
|
+
Better audio on a bad network.
|
|
1160
1164
|
|
|
1161
|
-
**`rtc` carries the screen too.**
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
in-app agent on `rtc` sees exactly what it sees on `ws`. Frames sent before the
|
|
1165
|
+
**`rtc` carries the screen too.** Screen context, actions and events travel on
|
|
1166
|
+
a second connection that Graine joins to the call, so an in-app agent on `rtc`
|
|
1167
|
+
sees exactly what it sees on `ws`. Frames sent before the
|
|
1165
1168
|
join completes are queued rather than dropped, because the first screen frame
|
|
1166
1169
|
almost always arrives while the phone is still ringing, and it is the one the
|
|
1167
1170
|
agent's opening line is composed from.
|
|
1168
1171
|
|
|
1169
1172
|
`rtc` **falls back.** WebRTC has more ways to be unavailable than a WebSocket —
|
|
1170
|
-
|
|
1173
|
+
WebRTC not set up for your organisation, media servers out of reach, a network
|
|
1174
|
+
that blocks UDP — and each
|
|
1171
1175
|
is a reason to carry the call differently rather than refuse it. The page
|
|
1172
1176
|
reconnects on the WebSocket and tells you through `onTransport`, so a permanent
|
|
1173
1177
|
misconfiguration is visible instead of being merely audible. A refused
|
|
@@ -1175,17 +1179,13 @@ microphone is **not** a transport problem and does not fall back.
|
|
|
1175
1179
|
|
|
1176
1180
|
---
|
|
1177
1181
|
|
|
1178
|
-
## Why there is no
|
|
1182
|
+
## Why there is no native setup
|
|
1179
1183
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
those is missed.
|
|
1184
|
+
The SDK needs no edit to `MainApplication.kt` or `AppDelegate.swift`, no pod
|
|
1185
|
+
install and no native rebuild. The audio runs in the same hosted engine as the
|
|
1186
|
+
web widget, so an improvement to the audio — where nearly every fix lands —
|
|
1187
|
+
reaches your app over the air, and integration is an npm install.
|
|
1185
1188
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
is very loud. What you get in exchange is that the audio path — where nearly
|
|
1190
|
-
every fix lands — ships over the air, and integration is an npm install rather
|
|
1191
|
-
than an afternoon in Xcode.
|
|
1189
|
+
The trade, stated plainly: a WebRTC call uses the phone's own echo canceller;
|
|
1190
|
+
on the WebSocket path a level-based guard stands in for it, and gives up when
|
|
1191
|
+
the speaker is very loud. Earphones or a lower volume settle it.
|
package/README.md
CHANGED
|
@@ -180,9 +180,8 @@ varies with the network and a player with no cushion runs dry in that gap. Hold
|
|
|
180
180
|
queue does run dry, refill to that again before resuming — otherwise one hiccup
|
|
181
181
|
becomes continuous stuttering for the rest of the turn.
|
|
182
182
|
|
|
183
|
-
Report what you are holding through `bufferedSeconds()
|
|
184
|
-
|
|
185
|
-
the runtime uses to know when it has finished speaking.
|
|
183
|
+
Report what you are holding through `bufferedSeconds()`, so the agent knows
|
|
184
|
+
when its reply has actually been heard rather than just received.
|
|
186
185
|
|
|
187
186
|
## Privacy
|
|
188
187
|
|
|
@@ -205,10 +204,19 @@ requests a permission on your behalf.
|
|
|
205
204
|
|
|
206
205
|
> **A publishable key inside a mobile app is public.** Anyone can extract it
|
|
207
206
|
> from an APK or IPA, and the web domain allowlist does not apply to mobile
|
|
208
|
-
> apps. Rate limits, single-use connection
|
|
209
|
-
> limits are what bound it. See the
|
|
207
|
+
> apps. Rate limits, single-use connection credentials and your account's
|
|
208
|
+
> credit limits are what bound it. See the
|
|
210
209
|
> [security notes](https://www.graine.ai/docs/in-app/privacy) before launch.
|
|
211
210
|
|
|
211
|
+
## Calls like WhatsApp
|
|
212
|
+
|
|
213
|
+
A call that rings the customer's phone — CallKit and VoIP pushes on iOS, a
|
|
214
|
+
ringing call notification and call screen on Android — and goes on with the
|
|
215
|
+
screen off comes in its own package, `@graineai/inapp-react-native-calls`,
|
|
216
|
+
which runs Graine's native call engine and shares this SDK's screen context and
|
|
217
|
+
actions (`GraineCalls.attach(client)`). It needs a new store build; this SDK
|
|
218
|
+
stays over-the-air. https://www.graine.ai/docs/in-app/calling#react-native
|
|
219
|
+
|
|
212
220
|
## Requirements
|
|
213
221
|
|
|
214
222
|
- React Native 0.68+
|
package/dist/client.d.ts
CHANGED
|
@@ -55,6 +55,10 @@ export declare class GraineInAppClient {
|
|
|
55
55
|
private legacyStall;
|
|
56
56
|
private pingTimer;
|
|
57
57
|
private agentSpeaking;
|
|
58
|
+
private openedAsVoice;
|
|
59
|
+
private lastAgentAudioAt;
|
|
60
|
+
private speakingTimer;
|
|
61
|
+
private spokenTurn;
|
|
58
62
|
private bargedIn;
|
|
59
63
|
private muted;
|
|
60
64
|
private playoutClock;
|
|
@@ -145,6 +149,8 @@ export declare class GraineInAppClient {
|
|
|
145
149
|
setPlayoutClock(fn: (() => number) | null): void;
|
|
146
150
|
get isAgentSpeaking(): boolean;
|
|
147
151
|
submitWidget(widgetId: string, data: Record<string, unknown>, summary: string): void;
|
|
152
|
+
private agentAudioArrived;
|
|
153
|
+
private scheduleSpeakingEnd;
|
|
148
154
|
private stopTimers;
|
|
149
155
|
close(): void;
|
|
150
156
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { contextToken, readAgentChunk, variablesForTicket, classifyQuality, ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
|
|
1
|
+
import { contextToken, readAgentChunk, spokenJoinGap, variablesForTicket, classifyQuality, ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
|
|
2
2
|
import { maskDeep } from "./mask.js";
|
|
3
3
|
import { FrictionDetector, resolveFriction } from "./friction.js";
|
|
4
4
|
export const SUBPROTOCOL = "graine.embed.v1";
|
|
5
5
|
const PING_MS = 25000;
|
|
6
|
+
const SPEAKING_TAIL_MS = 300;
|
|
6
7
|
export class GraineInAppClient {
|
|
7
8
|
allVariables() {
|
|
8
9
|
return {
|
|
@@ -79,6 +80,10 @@ export class GraineInAppClient {
|
|
|
79
80
|
this.legacyStall = false;
|
|
80
81
|
this.pingTimer = null;
|
|
81
82
|
this.agentSpeaking = false;
|
|
83
|
+
this.openedAsVoice = false;
|
|
84
|
+
this.lastAgentAudioAt = 0;
|
|
85
|
+
this.speakingTimer = null;
|
|
86
|
+
this.spokenTurn = "";
|
|
82
87
|
this.bargedIn = false;
|
|
83
88
|
this.muted = false;
|
|
84
89
|
this.playoutClock = null;
|
|
@@ -248,6 +253,7 @@ export class GraineInAppClient {
|
|
|
248
253
|
await this.session();
|
|
249
254
|
const agentId = this.config.webcallAgentId || this.config.agentId;
|
|
250
255
|
const t = await this.ticket();
|
|
256
|
+
this.openedAsVoice = this.socketIsVoice;
|
|
251
257
|
let url;
|
|
252
258
|
let protocols;
|
|
253
259
|
if (t?.wsUrl && t.ticket) {
|
|
@@ -319,15 +325,24 @@ export class GraineInAppClient {
|
|
|
319
325
|
switch (msg.type) {
|
|
320
326
|
case "text": {
|
|
321
327
|
const chunk = readAgentChunk(msg.data);
|
|
322
|
-
if (chunk.startsTurn && !this.agentSpeaking) {
|
|
328
|
+
if (!this.openedAsVoice && (chunk.startsTurn || chunk.text) && !this.agentSpeaking) {
|
|
323
329
|
this.agentSpeaking = true;
|
|
324
330
|
this.emit("agent_speaking", true);
|
|
325
331
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
this.
|
|
330
|
-
|
|
332
|
+
let words = chunk.text;
|
|
333
|
+
if (this.openedAsVoice && words) {
|
|
334
|
+
words = spokenJoinGap(this.spokenTurn, words) + words;
|
|
335
|
+
this.spokenTurn += words;
|
|
336
|
+
}
|
|
337
|
+
if (words)
|
|
338
|
+
this.emit("chunk", words);
|
|
339
|
+
if (chunk.endsTurn) {
|
|
340
|
+
this.spokenTurn = "";
|
|
341
|
+
this.emit("turn_end");
|
|
342
|
+
if (!this.openedAsVoice && this.agentSpeaking) {
|
|
343
|
+
this.agentSpeaking = false;
|
|
344
|
+
this.emit("agent_speaking", false);
|
|
345
|
+
}
|
|
331
346
|
}
|
|
332
347
|
break;
|
|
333
348
|
}
|
|
@@ -341,6 +356,7 @@ export class GraineInAppClient {
|
|
|
341
356
|
void this.runAction(msg.action);
|
|
342
357
|
break;
|
|
343
358
|
case "audio":
|
|
359
|
+
this.agentAudioArrived();
|
|
344
360
|
this.emit("audio", {
|
|
345
361
|
data: msg.data,
|
|
346
362
|
format: msg.meta_info?.format ?? null,
|
|
@@ -357,6 +373,9 @@ export class GraineInAppClient {
|
|
|
357
373
|
break;
|
|
358
374
|
case "clear":
|
|
359
375
|
case "interruption":
|
|
376
|
+
clearTimeout(this.speakingTimer);
|
|
377
|
+
this.speakingTimer = null;
|
|
378
|
+
this.spokenTurn = "";
|
|
360
379
|
this.agentSpeaking = false;
|
|
361
380
|
this.emit("clear");
|
|
362
381
|
break;
|
|
@@ -684,7 +703,33 @@ export class GraineInAppClient {
|
|
|
684
703
|
if (summary)
|
|
685
704
|
this.say(summary);
|
|
686
705
|
}
|
|
706
|
+
agentAudioArrived() {
|
|
707
|
+
this.lastAgentAudioAt = Date.now();
|
|
708
|
+
if (!this.agentSpeaking) {
|
|
709
|
+
this.agentSpeaking = true;
|
|
710
|
+
this.emit("agent_speaking", true);
|
|
711
|
+
}
|
|
712
|
+
this.scheduleSpeakingEnd(SPEAKING_TAIL_MS);
|
|
713
|
+
}
|
|
714
|
+
scheduleSpeakingEnd(delayMs) {
|
|
715
|
+
clearTimeout(this.speakingTimer);
|
|
716
|
+
this.speakingTimer = setTimeout(() => {
|
|
717
|
+
this.speakingTimer = null;
|
|
718
|
+
if (!this.agentSpeaking)
|
|
719
|
+
return;
|
|
720
|
+
const quietMs = Date.now() - this.lastAgentAudioAt;
|
|
721
|
+
const buffered = this.playoutClock?.() ?? 0;
|
|
722
|
+
if (quietMs >= SPEAKING_TAIL_MS && buffered <= 0.02) {
|
|
723
|
+
this.agentSpeaking = false;
|
|
724
|
+
this.emit("agent_speaking", false);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
this.scheduleSpeakingEnd(Math.min(1000, Math.max(50, buffered * 1000, SPEAKING_TAIL_MS - quietMs)));
|
|
728
|
+
}, delayMs);
|
|
729
|
+
}
|
|
687
730
|
stopTimers() {
|
|
731
|
+
clearTimeout(this.speakingTimer);
|
|
732
|
+
this.speakingTimer = null;
|
|
688
733
|
clearInterval(this.pingTimer);
|
|
689
734
|
clearTimeout(this.stallTimer);
|
|
690
735
|
clearTimeout(this.contextTimer);
|
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-nat
|
|
|
20
20
|
export type { GraineWidgetProps, GraineWidgetTheme } from "./react-native/widget.js";
|
|
21
21
|
export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
|
|
22
22
|
export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type FrictionConfig, type FrictionScreenRules, type FrictionNudge, type FrictionSignal, type FrictionKind, } from "./friction.js";
|
|
23
|
-
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
|
|
23
|
+
export { VoiceSession, AGENT_PCM_RATE, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
|
|
24
24
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
|
|
25
25
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
26
26
|
export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, useGraineTheme, type GraineEvent, type GraineProviderProps, type SvgKit, type Turn, type Caption, } from "./react-native/index.js";
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
|
|
|
11
11
|
export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-native/index.js";
|
|
12
12
|
export { GraineInAppClient, } from "./client.js";
|
|
13
13
|
export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
|
|
14
|
-
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
|
|
14
|
+
export { VoiceSession, AGENT_PCM_RATE, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
|
|
15
15
|
export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
|
|
16
16
|
export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
17
17
|
export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, useGraineTheme, } from "./react-native/index.js";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -194,6 +194,7 @@ export interface AgentChunk {
|
|
|
194
194
|
startsTurn: boolean;
|
|
195
195
|
endsTurn: boolean;
|
|
196
196
|
}
|
|
197
|
+
export declare function spokenJoinGap(soFar: string, next: string): string;
|
|
197
198
|
export declare function readAgentChunk(raw: unknown): AgentChunk;
|
|
198
199
|
export type ConnectionQuality = "excellent" | "good" | "poor" | "lost" | "unknown";
|
|
199
200
|
export interface QualitySample {
|
package/dist/protocol.js
CHANGED
|
@@ -322,6 +322,17 @@ export function resolveSurface(appearance, prefer) {
|
|
|
322
322
|
}
|
|
323
323
|
export const STREAM_START = "<beginning_of_stream>";
|
|
324
324
|
export const STREAM_END = "<end_of_stream>";
|
|
325
|
+
export function spokenJoinGap(soFar, next) {
|
|
326
|
+
if (!soFar || !next)
|
|
327
|
+
return "";
|
|
328
|
+
if (/\s$/.test(soFar) || /^\s/.test(next))
|
|
329
|
+
return "";
|
|
330
|
+
if (/^[,.;:!?)\]}…%'’”»।॥]/.test(next))
|
|
331
|
+
return "";
|
|
332
|
+
if (/[(\[{“‘«\/-]$/.test(soFar))
|
|
333
|
+
return "";
|
|
334
|
+
return " ";
|
|
335
|
+
}
|
|
325
336
|
export function readAgentChunk(raw) {
|
|
326
337
|
const text = raw == null ? "" : String(raw);
|
|
327
338
|
if (!text)
|
|
@@ -43,6 +43,7 @@ interface GraineContextValue {
|
|
|
43
43
|
fonts: Record<string, string> | null;
|
|
44
44
|
faces: LoadedFaces;
|
|
45
45
|
svg: SvgKit | null;
|
|
46
|
+
endOnBackground: boolean;
|
|
46
47
|
sessionReady: boolean;
|
|
47
48
|
onEvent: (fn: (e: GraineEvent) => void) => () => void;
|
|
48
49
|
track: (name: string, data?: Record<string, unknown>) => void;
|
|
@@ -390,9 +390,10 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
|
|
|
390
390
|
connectionQuality,
|
|
391
391
|
network,
|
|
392
392
|
setVariables: (vars) => client.setVariables(vars),
|
|
393
|
+
endOnBackground,
|
|
393
394
|
}), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
|
|
394
395
|
launcherVisible, launcherInset, currentScreen, onEvent, appearance, design, fonts, faces, svgKit, sessionReady,
|
|
395
|
-
surface, surfacePolicy, setSurface, connectionQuality, network]);
|
|
396
|
+
surface, surfacePolicy, setSurface, connectionQuality, network, endOnBackground]);
|
|
396
397
|
const wantsCapture = captureTaps ?? !!fab;
|
|
397
398
|
const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
|
|
398
399
|
return _jsx(Ctx.Provider, { value: value, children: body });
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { AppState, View } from "react-native";
|
|
4
|
-
import { useGraineAgent } from "./index.js";
|
|
4
|
+
import { Ctx, useGraineAgent } from "./index.js";
|
|
5
5
|
import { requestMicrophonePermission } from "./permissions.js";
|
|
6
6
|
export const CAPTION_HISTORY = 12;
|
|
7
7
|
export const SLOW_CONNECT_MS = 4000;
|
|
8
8
|
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active: activeProp = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
|
|
9
9
|
const { client, appearance, surface, surfaces } = useGraineAgent();
|
|
10
|
+
const endOnBackground = useContext(Ctx)?.endOnBackground ?? true;
|
|
11
|
+
const endOnBackgroundRef = useRef(endOnBackground);
|
|
12
|
+
endOnBackgroundRef.current = endOnBackground;
|
|
10
13
|
const active = activeProp && surface !== "chat";
|
|
11
14
|
const ref = useRef(null);
|
|
12
15
|
useEffect(() => {
|
|
@@ -518,7 +521,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
518
521
|
post({ type: "graine:warm" });
|
|
519
522
|
return;
|
|
520
523
|
}
|
|
521
|
-
if (next === "background" && wasActive && !pending) {
|
|
524
|
+
if (next === "background" && wasActive && !pending && endOnBackgroundRef.current) {
|
|
522
525
|
pending = setTimeout(() => {
|
|
523
526
|
pending = null;
|
|
524
527
|
if (AppState.currentState !== "active")
|
package/dist/voice.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GraineInAppClient } from "./client.js";
|
|
2
2
|
export declare const CAPTURE_SAMPLE_RATE = 16000;
|
|
3
|
+
export declare const AGENT_PCM_RATE = 24000;
|
|
3
4
|
export declare const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
|
|
4
5
|
export interface AudioAdapter {
|
|
5
6
|
startCapture(onChunk: (base64: string) => void): Promise<void> | void;
|
|
@@ -23,6 +24,7 @@ export declare class VoiceSession {
|
|
|
23
24
|
private echo;
|
|
24
25
|
private agentAudible;
|
|
25
26
|
private lastAudioAt;
|
|
27
|
+
private audibleUntil;
|
|
26
28
|
constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
|
|
27
29
|
get active(): boolean;
|
|
28
30
|
start(): Promise<void>;
|
package/dist/voice.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EchoGuard, base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio.js";
|
|
2
2
|
export const CAPTURE_SAMPLE_RATE = 16000;
|
|
3
|
+
export const AGENT_PCM_RATE = 24000;
|
|
3
4
|
export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
|
|
4
5
|
export function base64ToPcm16(b64) {
|
|
5
6
|
return decodePcm16(base64ToBytes(b64));
|
|
@@ -15,6 +16,7 @@ export class VoiceSession {
|
|
|
15
16
|
this.echo = new EchoGuard();
|
|
16
17
|
this.agentAudible = false;
|
|
17
18
|
this.lastAudioAt = 0;
|
|
19
|
+
this.audibleUntil = 0;
|
|
18
20
|
}
|
|
19
21
|
get active() {
|
|
20
22
|
return this.capturing;
|
|
@@ -25,13 +27,14 @@ export class VoiceSession {
|
|
|
25
27
|
this.capturing = true;
|
|
26
28
|
this.offs = [
|
|
27
29
|
this.client.on("audio", ({ data, format, sampleRate, }) => {
|
|
28
|
-
const decoded = decodeAgentAudio(base64ToBytes(data), { format, sampleRate }, this.format);
|
|
30
|
+
const decoded = decodeAgentAudio(base64ToBytes(data), { format, sampleRate }, this.format, AGENT_PCM_RATE);
|
|
29
31
|
if (!decoded)
|
|
30
32
|
return;
|
|
31
33
|
if (decoded.latch)
|
|
32
34
|
this.format = decoded.latch;
|
|
33
35
|
this.lastAudioAt = Date.now();
|
|
34
36
|
this.adapter.play(decoded.pcm16, decoded.sampleRate);
|
|
37
|
+
this.audibleUntil = Date.now() + (this.adapter.bufferedSeconds?.() ?? 0) * 1000;
|
|
35
38
|
}),
|
|
36
39
|
this.client.on("clear", () => {
|
|
37
40
|
this.adapter.clear();
|
|
@@ -59,7 +62,7 @@ export class VoiceSession {
|
|
|
59
62
|
});
|
|
60
63
|
}
|
|
61
64
|
agentIsAudible() {
|
|
62
|
-
if (Date.now() - this.lastAudioAt > 1500)
|
|
65
|
+
if (Date.now() - Math.max(this.lastAudioAt, this.audibleUntil) > 1500)
|
|
63
66
|
return false;
|
|
64
67
|
const buffered = this.adapter.bufferedSeconds?.();
|
|
65
68
|
return buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
|
|
@@ -71,6 +74,7 @@ export class VoiceSession {
|
|
|
71
74
|
this.format = null;
|
|
72
75
|
this.agentAudible = false;
|
|
73
76
|
this.lastAudioAt = 0;
|
|
77
|
+
this.audibleUntil = 0;
|
|
74
78
|
this.echo.reset();
|
|
75
79
|
this.offs.forEach((off) => off());
|
|
76
80
|
this.offs = [];
|
package/package.json
CHANGED