@graineai/inapp-react-native 0.2.0 → 0.3.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/README.md CHANGED
@@ -130,6 +130,17 @@ particular native audio module into your build. `liveAudioStreamAdapter` wraps
130
130
 
131
131
  Capture must be **16 kHz mono PCM16**. Interruption is handled for you.
132
132
 
133
+ **Buffer before you play.** A player that plays each frame as it arrives will
134
+ break up: a WebSocket gives no timing guarantee, so the gap between frames
135
+ varies with the network and a player with no cushion runs dry in that gap. Hold
136
+ **at least 150ms** (`MIN_PLAYOUT_BUFFER_SECONDS`) before starting, and when the
137
+ queue does run dry, refill to that again before resuming — otherwise one hiccup
138
+ becomes continuous stuttering for the rest of the turn.
139
+
140
+ Report what you are holding through `bufferedSeconds()` and the SDK will
141
+ acknowledge the agent's audio at playout rather than on arrival, which is what
142
+ the runtime uses to know when it has finished speaking.
143
+
133
144
  ## Privacy
134
145
 
135
146
  Identifiers are masked on the device before any screen data is sent — PAN,
@@ -0,0 +1,17 @@
1
+ export interface AudioFormat {
2
+ kind: "mulaw" | "pcm";
3
+ sampleRate: number;
4
+ }
5
+ export declare function decodeMuLaw(bytes: Uint8Array): Int16Array;
6
+ export declare function decodePcm16(bytes: Uint8Array): Int16Array;
7
+ export interface DecodedAudio {
8
+ pcm16: Int16Array;
9
+ sampleRate: number;
10
+ kind: "mulaw" | "pcm";
11
+ latch: AudioFormat | null;
12
+ }
13
+ export declare function decodeAgentAudio(bytes: Uint8Array, declared: {
14
+ format?: string | null;
15
+ sampleRate?: number | null;
16
+ } | null, latched: AudioFormat | null, fallbackPcmRate?: number): DecodedAudio | null;
17
+ export declare function base64ToBytes(b64: string): Uint8Array;
package/dist/audio.js ADDED
@@ -0,0 +1,82 @@
1
+ export function decodeMuLaw(bytes) {
2
+ const out = new Int16Array(bytes.length);
3
+ const BIAS = 0x84;
4
+ for (let i = 0; i < bytes.length; i++) {
5
+ const mu = ~bytes[i] & 0xff;
6
+ const sign = mu & 0x80;
7
+ const exponent = (mu >> 4) & 0x07;
8
+ const mantissa = mu & 0x0f;
9
+ const t = ((mantissa << 3) + BIAS) << exponent;
10
+ out[i] = sign ? BIAS - t : t - BIAS;
11
+ }
12
+ return out;
13
+ }
14
+ export function decodePcm16(bytes) {
15
+ const out = new Int16Array(bytes.length >> 1);
16
+ for (let i = 0; i < out.length; i++) {
17
+ out[i] = ((bytes[i * 2] | (bytes[i * 2 + 1] << 8)) << 16) >> 16;
18
+ }
19
+ return out;
20
+ }
21
+ function zeroCrossingRate(pcm) {
22
+ if (pcm.length < 2)
23
+ return 1;
24
+ let crossings = 0;
25
+ for (let i = 1; i < pcm.length; i++) {
26
+ if ((pcm[i] < 0) !== (pcm[i - 1] < 0))
27
+ crossings++;
28
+ }
29
+ return crossings / (pcm.length - 1);
30
+ }
31
+ function rms(pcm) {
32
+ if (pcm.length === 0)
33
+ return 0;
34
+ let sum = 0;
35
+ for (let i = 0; i < pcm.length; i++)
36
+ sum += pcm[i] * pcm[i];
37
+ return Math.sqrt(sum / pcm.length);
38
+ }
39
+ const CONFIDENT_RMS = 500;
40
+ export function decodeAgentAudio(bytes, declared, latched, fallbackPcmRate = 16000) {
41
+ if (bytes.length === 0)
42
+ return null;
43
+ const fmt = declared?.format?.toLowerCase();
44
+ if (fmt === "mulaw" || fmt === "ulaw" || fmt === "g711_ulaw" || fmt === "pcmu") {
45
+ const sampleRate = declared?.sampleRate || 8000;
46
+ return { pcm16: decodeMuLaw(bytes), sampleRate, kind: "mulaw", latch: { kind: "mulaw", sampleRate } };
47
+ }
48
+ if (fmt === "pcm" || fmt === "linear16" || fmt === "raw") {
49
+ const sampleRate = declared?.sampleRate || fallbackPcmRate;
50
+ return { pcm16: decodePcm16(bytes), sampleRate, kind: "pcm", latch: { kind: "pcm", sampleRate } };
51
+ }
52
+ if (latched) {
53
+ return latched.kind === "mulaw"
54
+ ? { pcm16: decodeMuLaw(bytes), sampleRate: latched.sampleRate, kind: "mulaw", latch: latched }
55
+ : { pcm16: decodePcm16(bytes), sampleRate: latched.sampleRate, kind: "pcm", latch: latched };
56
+ }
57
+ const asMuLaw = decodeMuLaw(bytes);
58
+ const asPcm = decodePcm16(bytes);
59
+ const muLawWins = zeroCrossingRate(asMuLaw) <= zeroCrossingRate(asPcm);
60
+ const pcm16 = muLawWins ? asMuLaw : asPcm;
61
+ const kind = muLawWins ? "mulaw" : "pcm";
62
+ const sampleRate = muLawWins ? 8000 : declared?.sampleRate || fallbackPcmRate;
63
+ const confident = rms(pcm16) >= CONFIDENT_RMS;
64
+ return { pcm16, sampleRate, kind, latch: confident ? { kind, sampleRate } : null };
65
+ }
66
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
67
+ export function base64ToBytes(b64) {
68
+ const clean = b64.replace(/[^A-Za-z0-9+/]/g, "");
69
+ const bytes = new Uint8Array((clean.length * 3) >> 2);
70
+ let byte = 0;
71
+ let acc = 0;
72
+ let bits = 0;
73
+ for (let i = 0; i < clean.length; i++) {
74
+ acc = (acc << 6) | B64.indexOf(clean[i]);
75
+ bits += 6;
76
+ if (bits >= 8) {
77
+ bits -= 8;
78
+ bytes[byte++] = (acc >> bits) & 0xff;
79
+ }
80
+ }
81
+ return bytes.subarray(0, byte);
82
+ }
package/dist/client.d.ts CHANGED
@@ -35,6 +35,8 @@ export declare class GraineInAppClient {
35
35
  private pingTimer;
36
36
  private agentSpeaking;
37
37
  private bargedIn;
38
+ private muted;
39
+ private playoutClock;
38
40
  constructor(opts: GraineInAppOptions);
39
41
  on(event: string, fn: Listener): () => void;
40
42
  private emit;
@@ -56,6 +58,9 @@ export declare class GraineInAppClient {
56
58
  say(text: string): boolean;
57
59
  sendAudio(base64: string, sampleRate?: number): boolean;
58
60
  endUtterance(): boolean;
61
+ setMuted(muted: boolean): void;
62
+ get isMuted(): boolean;
63
+ setPlayoutClock(fn: (() => number) | null): void;
59
64
  get isAgentSpeaking(): boolean;
60
65
  submitWidget(widgetId: string, data: Record<string, unknown>, summary: string): void;
61
66
  private stopTimers;
package/dist/client.js CHANGED
@@ -19,6 +19,8 @@ export class GraineInAppClient {
19
19
  this.pingTimer = null;
20
20
  this.agentSpeaking = false;
21
21
  this.bargedIn = false;
22
+ this.muted = false;
23
+ this.playoutClock = null;
22
24
  if (!opts?.publishableKey)
23
25
  throw new Error("[Graine] publishableKey is required.");
24
26
  if (!opts?.baseUrl)
@@ -69,7 +71,10 @@ export class GraineInAppClient {
69
71
  const res = await this.fetch(`${this.opts.baseUrl}/api/embed/ticket`, {
70
72
  method: "POST",
71
73
  headers: { "Content-Type": "application/json" },
72
- body: JSON.stringify({ publishableKey: this.opts.publishableKey }),
74
+ body: JSON.stringify({
75
+ publishableKey: this.opts.publishableKey,
76
+ mode: this.opts.voice ? "voice" : "text",
77
+ }),
73
78
  });
74
79
  if (!res.ok)
75
80
  return null;
@@ -118,7 +123,11 @@ export class GraineInAppClient {
118
123
  };
119
124
  this.send({
120
125
  type: "init",
121
- meta_data: { context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) } },
126
+ event: "start",
127
+ meta_data: {
128
+ client: "graine-inapp-sdk",
129
+ context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) },
130
+ },
122
131
  });
123
132
  this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
124
133
  if (this.screen)
@@ -158,7 +167,8 @@ export class GraineInAppClient {
158
167
  case "audio":
159
168
  this.emit("audio", {
160
169
  data: msg.data,
161
- sampleRate: msg.meta_info?.sample_rate ?? 16000,
170
+ format: msg.meta_info?.format ?? null,
171
+ sampleRate: msg.meta_info?.sample_rate ?? null,
162
172
  });
163
173
  break;
164
174
  case "beginning_of_stream":
@@ -180,6 +190,18 @@ export class GraineInAppClient {
180
190
  case "transcript":
181
191
  this.emit("transcript", { text: msg.data ?? "", final: true });
182
192
  break;
193
+ case "mark": {
194
+ const name = msg.name;
195
+ if (!name)
196
+ break;
197
+ const waitMs = Math.min(Math.max(0, (this.playoutClock?.() ?? 0) * 1000), 10000);
198
+ const ack = () => this.send({ type: "mark", name });
199
+ if (waitMs <= 0)
200
+ ack();
201
+ else
202
+ setTimeout(ack, waitMs);
203
+ break;
204
+ }
183
205
  case "pong":
184
206
  case "ack":
185
207
  break;
@@ -296,6 +318,8 @@ export class GraineInAppClient {
296
318
  return this.send({ type: "text", data: text });
297
319
  }
298
320
  sendAudio(base64, sampleRate = 16000) {
321
+ if (this.muted)
322
+ return false;
299
323
  if (this.agentSpeaking && !this.bargedIn) {
300
324
  this.bargedIn = true;
301
325
  this.send({ type: "barge_in" });
@@ -312,6 +336,18 @@ export class GraineInAppClient {
312
336
  endUtterance() {
313
337
  return this.send({ type: "utterance_end" });
314
338
  }
339
+ setMuted(muted) {
340
+ if (this.muted === muted)
341
+ return;
342
+ this.muted = muted;
343
+ this.emit("muted", muted);
344
+ }
345
+ get isMuted() {
346
+ return this.muted;
347
+ }
348
+ setPlayoutClock(fn) {
349
+ this.playoutClock = fn;
350
+ }
315
351
  get isAgentSpeaking() {
316
352
  return this.agentSpeaking;
317
353
  }
@@ -328,11 +364,22 @@ export class GraineInAppClient {
328
364
  }
329
365
  close() {
330
366
  this.stopTimers();
367
+ const sock = this.ws;
368
+ this.ws = null;
369
+ if (!sock)
370
+ return;
331
371
  try {
332
- this.ws?.close();
372
+ if (sock.readyState === 1) {
373
+ sock.send(JSON.stringify({ type: "stop", event: "stop", data: "client_hangup" }));
374
+ setTimeout(() => { try {
375
+ sock.close();
376
+ }
377
+ catch { } }, 200);
378
+ return;
379
+ }
380
+ sock.close();
333
381
  }
334
382
  catch {
335
383
  }
336
- this.ws = null;
337
384
  }
338
385
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, } from "./protocol";
2
2
  export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "./client";
3
3
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
4
+ export { decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
4
5
  export { addMaskRule, maskDeep, maskString } from "./mask";
5
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, } from "./react-native/index";
6
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
6
7
  export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { GraineInAppClient } from "./client";
2
2
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
3
+ export { decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
3
4
  export { addMaskRule, maskDeep, maskString } from "./mask";
4
5
  export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, } from "./react-native/index";
5
6
  export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
@@ -12,6 +12,15 @@ interface GraineContextValue {
12
12
  messages: Turn[];
13
13
  widgets: any[];
14
14
  send: (text: string) => void;
15
+ muted: boolean;
16
+ setMuted: (muted: boolean) => void;
17
+ agentSpeaking: boolean;
18
+ caption: Caption | null;
19
+ }
20
+ export interface Caption {
21
+ role: "agent" | "customer";
22
+ text: string;
23
+ live: boolean;
15
24
  }
16
25
  export interface Turn {
17
26
  role: "agent" | "customer";
@@ -14,6 +14,9 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
14
14
  const [open, setOpen] = useState(false);
15
15
  const [messages, setMessages] = useState([]);
16
16
  const [widgets, setWidgets] = useState([]);
17
+ const [muted, setMutedState] = useState(false);
18
+ const [agentSpeaking, setAgentSpeaking] = useState(false);
19
+ const [caption, setCaption] = useState(null);
17
20
  const proactiveRef = useRef(onProactive);
18
21
  proactiveRef.current = onProactive;
19
22
  const openRef = useRef(open);
@@ -39,6 +42,19 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
39
42
  return [...prev, { role: "agent", text, live: true }];
40
43
  })),
41
44
  client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
45
+ client.on("muted", (m) => setMutedState(m)),
46
+ client.on("agent_speaking", (speaking) => {
47
+ setAgentSpeaking(speaking);
48
+ if (speaking)
49
+ setCaption(null);
50
+ }),
51
+ client.on("transcript", ({ text, final }) => {
52
+ if (text)
53
+ setCaption({ role: "customer", text, live: !final });
54
+ }),
55
+ client.on("chunk", (text) => setCaption((prev) => prev && prev.role === "agent" && prev.live
56
+ ? { ...prev, text: prev.text + text }
57
+ : { role: "agent", text, live: true })),
42
58
  ];
43
59
  if (autoConnect) {
44
60
  setConnecting(true);
@@ -62,6 +78,10 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
62
78
  setOpen,
63
79
  messages,
64
80
  widgets,
81
+ muted,
82
+ agentSpeaking,
83
+ caption,
84
+ setMuted: (m) => client.setMuted(m),
65
85
  send: (text) => {
66
86
  if (!text.trim())
67
87
  return;
@@ -72,7 +92,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
72
92
  client.noteInteraction();
73
93
  client.say(text);
74
94
  },
75
- }), [client, connected, connecting, error, open, messages, widgets]);
95
+ }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption]);
76
96
  return _jsx(Ctx.Provider, { value: value, children: children });
77
97
  }
78
98
  function useGraine() {
@@ -2,9 +2,11 @@ import React from "react";
2
2
  export interface GraineAgentBarProps {
3
3
  accent?: string;
4
4
  name?: string;
5
+ avatarUrl?: string;
5
6
  bottomInset?: number;
6
7
  hidden?: boolean;
8
+ captionsOn?: boolean;
7
9
  }
8
- export declare function GraineAgentBar({ accent, name, bottomInset, hidden, }: GraineAgentBarProps): React.JSX.Element | null;
10
+ export declare function GraineAgentBar({ accent, name, avatarUrl, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
9
11
  export declare const GraineLauncher: typeof GraineAgentBar;
10
12
  export type GraineLauncherProps = GraineAgentBarProps;
@@ -1,19 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
3
+ import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
4
4
  import { useGraineAgent } from "./index";
5
- export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", bottomInset = 96, hidden = false, }) {
6
- const { connected, connecting, error, messages, send } = useGraineAgent();
5
+ export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", avatarUrl, bottomInset = 96, hidden = false, captionsOn = false, }) {
6
+ const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking } = useGraineAgent();
7
7
  const [expanded, setExpanded] = useState(false);
8
+ const [captions, setCaptions] = useState(captionsOn);
8
9
  const [draft, setDraft] = useState("");
9
10
  const scrollRef = useRef(null);
10
11
  const last = messages[messages.length - 1];
11
12
  const status = error
12
13
  ? "unavailable"
13
14
  : connecting ? "connecting…"
14
- : last?.role === "agent" && last.live ? "speaking"
15
- : connected ? "listening"
16
- : "starting…";
15
+ : muted ? "muted"
16
+ : agentSpeaking || (last?.role === "agent" && last.live) ? "speaking"
17
+ : connected ? "listening"
18
+ : "starting…";
17
19
  useEffect(() => {
18
20
  if (expanded)
19
21
  requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
@@ -34,7 +36,7 @@ export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", bottomI
34
36
  return (_jsxs(View, { pointerEvents: "box-none", style: [styles.root, { bottom: bottomInset }], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: "I can see what you're working on \u2014 ask me anything, or I'll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
35
37
  styles.bubble,
36
38
  m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
37
- ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: "#8a8f98", onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), _jsxs(View, { style: styles.bar, children: [_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) }), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }), connecting ? _jsx(ActivityIndicator, { size: "small", color: accent }) : null, _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
39
+ ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: "#8a8f98", onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }), connecting ? _jsx(ActivityIndicator, { size: "small", color: accent }) : null, _jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && { color: "#fff" }], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && { color: "#fff" }], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
38
40
  }
39
41
  export const GraineLauncher = GraineAgentBar;
40
42
  const styles = StyleSheet.create({
@@ -65,6 +67,14 @@ const styles = StyleSheet.create({
65
67
  shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12,
66
68
  },
67
69
  avatar: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
70
+ avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: "rgba(255,255,255,0.08)" },
71
+ captionWrap: {
72
+ backgroundColor: "rgba(0,0,0,0.72)", borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, marginBottom: 8,
73
+ },
74
+ caption: { color: "#fff", fontSize: 15, lineHeight: 21, fontWeight: "600" },
75
+ captionLive: { opacity: 0.8 },
76
+ glyph: { color: "#F2F3F5", fontSize: 13, fontWeight: "800" },
77
+ muted: { backgroundColor: "#E5484D" },
68
78
  avatarGlyph: { color: "#fff", fontSize: 17, lineHeight: 20 },
69
79
  name: { color: "#F2F3F5", fontSize: 15, fontWeight: "800" },
70
80
  status: { color: "#9aa0a6", fontSize: 12.5, marginTop: 1 },
package/dist/voice.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { GraineInAppClient } from "./client";
2
2
  export declare const CAPTURE_SAMPLE_RATE = 16000;
3
+ export declare const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
3
4
  export interface AudioAdapter {
4
5
  startCapture(onChunk: (base64: string) => void): Promise<void> | void;
5
6
  stopCapture(): Promise<void> | void;
6
7
  play(pcm16: Int16Array, sampleRate: number): void;
7
8
  clear(): void;
9
+ bufferedSeconds?(): number;
8
10
  }
9
11
  export declare function base64ToPcm16(b64: string): Int16Array;
10
12
  export interface VoiceSessionOptions {
@@ -17,6 +19,7 @@ export declare class VoiceSession {
17
19
  private options;
18
20
  private offs;
19
21
  private capturing;
22
+ private format;
20
23
  constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
21
24
  get active(): boolean;
22
25
  start(): Promise<void>;
package/dist/voice.js CHANGED
@@ -1,24 +1,8 @@
1
+ import { base64ToBytes, decodeAgentAudio, decodePcm16 } from "./audio";
1
2
  export const CAPTURE_SAMPLE_RATE = 16000;
2
- const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3
+ export const MIN_PLAYOUT_BUFFER_SECONDS = 0.15;
3
4
  export function base64ToPcm16(b64) {
4
- const clean = b64.replace(/[^A-Za-z0-9+/]/g, "");
5
- const bytes = new Uint8Array((clean.length * 3) >> 2);
6
- let byte = 0;
7
- let acc = 0;
8
- let bits = 0;
9
- for (let i = 0; i < clean.length; i++) {
10
- acc = (acc << 6) | B64.indexOf(clean[i]);
11
- bits += 6;
12
- if (bits >= 8) {
13
- bits -= 8;
14
- bytes[byte++] = (acc >> bits) & 0xff;
15
- }
16
- }
17
- const samples = new Int16Array(byte >> 1);
18
- for (let i = 0; i < samples.length; i++) {
19
- samples[i] = (bytes[i * 2] | (bytes[i * 2 + 1] << 8)) << 16 >> 16;
20
- }
21
- return samples;
5
+ return decodePcm16(base64ToBytes(b64));
22
6
  }
23
7
  export class VoiceSession {
24
8
  constructor(client, adapter, options = {}) {
@@ -27,6 +11,7 @@ export class VoiceSession {
27
11
  this.options = options;
28
12
  this.offs = [];
29
13
  this.capturing = false;
14
+ this.format = null;
30
15
  }
31
16
  get active() {
32
17
  return this.capturing;
@@ -36,8 +21,13 @@ export class VoiceSession {
36
21
  return;
37
22
  this.capturing = true;
38
23
  this.offs = [
39
- this.client.on("audio", ({ data, sampleRate }) => {
40
- this.adapter.play(base64ToPcm16(data), sampleRate);
24
+ this.client.on("audio", ({ data, format, sampleRate, }) => {
25
+ const decoded = decodeAgentAudio(base64ToBytes(data), { format, sampleRate }, this.format);
26
+ if (!decoded)
27
+ return;
28
+ if (decoded.latch)
29
+ this.format = decoded.latch;
30
+ this.adapter.play(decoded.pcm16, decoded.sampleRate);
41
31
  }),
42
32
  this.client.on("clear", () => {
43
33
  this.adapter.clear();
@@ -50,12 +40,14 @@ export class VoiceSession {
50
40
  this.options.onTranscript?.(text, final);
51
41
  }),
52
42
  ];
43
+ this.client.setPlayoutClock(() => this.adapter.bufferedSeconds?.() ?? 0);
53
44
  await this.adapter.startCapture((base64) => this.client.sendAudio(base64, CAPTURE_SAMPLE_RATE));
54
45
  }
55
46
  async stop() {
56
47
  if (!this.capturing)
57
48
  return;
58
49
  this.capturing = false;
50
+ this.format = null;
59
51
  this.offs.forEach((off) => off());
60
52
  this.offs = [];
61
53
  this.adapter.clear();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.2.0",
4
- "description": "Graine in-app agent for React Native \u2014 an agent that sees the screen your customer is on and can act on it.",
3
+ "version": "0.3.1",
4
+ "description": "Graine in-app agent for React Native an agent that sees the screen your customer is on and can act on it.",
5
5
  "license": "MIT",
6
6
  "private": false,
7
7
  "main": "dist/index.js",