@jchaffin/voicekit 0.2.0 → 0.2.2

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.
@@ -73,12 +73,18 @@ var DeepgramSession = class extends EventEmitter {
73
73
  if (this.options.model) url.searchParams.set("model", this.options.model);
74
74
  if (this.options.language) url.searchParams.set("language", this.options.language);
75
75
  this.ws = new WebSocket(url.toString());
76
- await new Promise((resolve, reject) => {
77
- const ws = this.ws;
78
- ws.onopen = () => resolve();
79
- ws.onerror = (e) => reject(new Error("WebSocket connection failed"));
80
- ws.onclose = () => this.emit("status_change", "DISCONNECTED");
81
- });
76
+ try {
77
+ await new Promise((resolve, reject) => {
78
+ const ws = this.ws;
79
+ ws.onopen = () => resolve();
80
+ ws.onerror = () => reject(new Error("WebSocket connection failed"));
81
+ ws.onclose = () => this.emit("status_change", "DISCONNECTED");
82
+ });
83
+ } catch (err) {
84
+ this.ws?.close();
85
+ this.ws = null;
86
+ throw err;
87
+ }
82
88
  this.ws.onmessage = (event) => {
83
89
  try {
84
90
  const msg = JSON.parse(event.data);
@@ -19,12 +19,18 @@ var DeepgramSession = class extends EventEmitter {
19
19
  if (this.options.model) url.searchParams.set("model", this.options.model);
20
20
  if (this.options.language) url.searchParams.set("language", this.options.language);
21
21
  this.ws = new WebSocket(url.toString());
22
- await new Promise((resolve, reject) => {
23
- const ws = this.ws;
24
- ws.onopen = () => resolve();
25
- ws.onerror = (e) => reject(new Error("WebSocket connection failed"));
26
- ws.onclose = () => this.emit("status_change", "DISCONNECTED");
27
- });
22
+ try {
23
+ await new Promise((resolve, reject) => {
24
+ const ws = this.ws;
25
+ ws.onopen = () => resolve();
26
+ ws.onerror = () => reject(new Error("WebSocket connection failed"));
27
+ ws.onclose = () => this.emit("status_change", "DISCONNECTED");
28
+ });
29
+ } catch (err) {
30
+ this.ws?.close();
31
+ this.ws = null;
32
+ throw err;
33
+ }
28
34
  this.ws.onmessage = (event) => {
29
35
  try {
30
36
  const msg = JSON.parse(event.data);
@@ -73,11 +73,17 @@ var ElevenLabsSession = class extends EventEmitter {
73
73
  async connect(config) {
74
74
  const wsUrl = config.authToken?.startsWith("wss://") ? config.authToken : `${ELEVENLABS_WS_BASE}?agent_id=${this.agentId}`;
75
75
  this.ws = new WebSocket(wsUrl);
76
- await new Promise((resolve, reject) => {
77
- const ws = this.ws;
78
- ws.onopen = () => resolve();
79
- ws.onerror = () => reject(new Error("ElevenLabs WebSocket connection failed"));
80
- });
76
+ try {
77
+ await new Promise((resolve, reject) => {
78
+ const ws = this.ws;
79
+ ws.onopen = () => resolve();
80
+ ws.onerror = () => reject(new Error("ElevenLabs WebSocket connection failed"));
81
+ });
82
+ } catch (err) {
83
+ this.ws?.close();
84
+ this.ws = null;
85
+ throw err;
86
+ }
81
87
  this.ws.onmessage = (event) => {
82
88
  try {
83
89
  const msg = JSON.parse(event.data);
@@ -19,11 +19,17 @@ var ElevenLabsSession = class extends EventEmitter {
19
19
  async connect(config) {
20
20
  const wsUrl = config.authToken?.startsWith("wss://") ? config.authToken : `${ELEVENLABS_WS_BASE}?agent_id=${this.agentId}`;
21
21
  this.ws = new WebSocket(wsUrl);
22
- await new Promise((resolve, reject) => {
23
- const ws = this.ws;
24
- ws.onopen = () => resolve();
25
- ws.onerror = () => reject(new Error("ElevenLabs WebSocket connection failed"));
26
- });
22
+ try {
23
+ await new Promise((resolve, reject) => {
24
+ const ws = this.ws;
25
+ ws.onopen = () => resolve();
26
+ ws.onerror = () => reject(new Error("ElevenLabs WebSocket connection failed"));
27
+ });
28
+ } catch (err) {
29
+ this.ws?.close();
30
+ this.ws = null;
31
+ throw err;
32
+ }
27
33
  this.ws.onmessage = (event) => {
28
34
  try {
29
35
  const msg = JSON.parse(event.data);
@@ -106,9 +106,15 @@ var LiveKitSession = class extends EventEmitter {
106
106
  this.room.on(RoomEvent.Reconnected, () => {
107
107
  this.emit("status_change", "CONNECTED");
108
108
  });
109
- await this.room.connect(this.serverUrl, config.authToken);
110
- await this.room.localParticipant.setMicrophoneEnabled(true);
111
- this.emit("status_change", "CONNECTED");
109
+ try {
110
+ await this.room.connect(this.serverUrl, config.authToken);
111
+ await this.room.localParticipant.setMicrophoneEnabled(true);
112
+ this.emit("status_change", "CONNECTED");
113
+ } catch (err) {
114
+ await this.room.disconnect();
115
+ this.room = null;
116
+ throw err;
117
+ }
112
118
  }
113
119
  async disconnect() {
114
120
  if (this.room) {
@@ -42,9 +42,15 @@ var LiveKitSession = class extends EventEmitter {
42
42
  this.room.on(RoomEvent.Reconnected, () => {
43
43
  this.emit("status_change", "CONNECTED");
44
44
  });
45
- await this.room.connect(this.serverUrl, config.authToken);
46
- await this.room.localParticipant.setMicrophoneEnabled(true);
47
- this.emit("status_change", "CONNECTED");
45
+ try {
46
+ await this.room.connect(this.serverUrl, config.authToken);
47
+ await this.room.localParticipant.setMicrophoneEnabled(true);
48
+ this.emit("status_change", "CONNECTED");
49
+ } catch (err) {
50
+ await this.room.disconnect();
51
+ this.room = null;
52
+ throw err;
53
+ }
48
54
  }
49
55
  async disconnect() {
50
56
  if (this.room) {
@@ -152,12 +152,16 @@ var OpenAISession = class extends EventEmitter {
152
152
  await new Promise((resolve) => {
153
153
  const onDone = (event) => {
154
154
  if (event.type === "response.done" || event.type === "response.cancelled") {
155
+ clearTimeout(timeoutId);
155
156
  this.off("raw_event", onDone);
156
157
  resolve();
157
158
  }
158
159
  };
159
160
  this.on("raw_event", onDone);
160
- setTimeout(resolve, 1500);
161
+ const timeoutId = setTimeout(() => {
162
+ this.off("raw_event", onDone);
163
+ resolve();
164
+ }, 1500);
161
165
  });
162
166
  }
163
167
  this.session.sendMessage(text);
@@ -96,12 +96,16 @@ var OpenAISession = class extends EventEmitter {
96
96
  await new Promise((resolve) => {
97
97
  const onDone = (event) => {
98
98
  if (event.type === "response.done" || event.type === "response.cancelled") {
99
+ clearTimeout(timeoutId);
99
100
  this.off("raw_event", onDone);
100
101
  resolve();
101
102
  }
102
103
  };
103
104
  this.on("raw_event", onDone);
104
- setTimeout(resolve, 1500);
105
+ const timeoutId = setTimeout(() => {
106
+ this.off("raw_event", onDone);
107
+ resolve();
108
+ }, 1500);
105
109
  });
106
110
  }
107
111
  this.session.sendMessage(text);
package/dist/index.js CHANGED
@@ -217,6 +217,7 @@ function VoiceProvider({
217
217
  }, 500);
218
218
  } catch (error) {
219
219
  console.error("VoiceKit connection failed:", error);
220
+ sessionRef.current = null;
220
221
  onError?.(error instanceof Error ? error : new Error(String(error)));
221
222
  updateStatus("DISCONNECTED");
222
223
  }
@@ -823,6 +824,7 @@ function useAudioRecorder() {
823
824
  if (mediaRecorderRef.current?.state === "recording") {
824
825
  return;
825
826
  }
827
+ recordedChunksRef.current = [];
826
828
  try {
827
829
  const mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
828
830
  mediaRecorder.ondataavailable = (event) => {
@@ -1172,11 +1174,7 @@ function useSessionHistory() {
1172
1174
  const { itemId, role, content = [] } = item;
1173
1175
  if (itemId && role) {
1174
1176
  let text = extractMessageText(content);
1175
- if (role === "assistant" && !text) {
1176
- text = "";
1177
- } else if (role === "user" && !text) {
1178
- return;
1179
- }
1177
+ if (!text) text = "";
1180
1178
  const guardrailMessage = sketchilyDetectGuardrailMessage(text);
1181
1179
  if (guardrailMessage) {
1182
1180
  const failureDetails = JSON.parse(guardrailMessage);
@@ -1219,6 +1217,7 @@ function useSessionHistory() {
1219
1217
  const itemId = item.item_id;
1220
1218
  if (interruptedItemsRef.current.has(itemId)) return;
1221
1219
  if (itemId) {
1220
+ const displayedText = displayedTextRef.current.get(itemId) ?? accumulatedTextRef.current.get(itemId);
1222
1221
  const timer = deltaTimerRef.current.get(itemId);
1223
1222
  if (timer) clearTimeout(timer);
1224
1223
  deltaTimerRef.current.delete(itemId);
@@ -1227,14 +1226,13 @@ function useSessionHistory() {
1227
1226
  displayedTextRef.current.delete(itemId);
1228
1227
  accumulatedTextRef.current.delete(itemId);
1229
1228
  totalAudioDurationRef.current.delete(itemId);
1230
- const displayedText = displayedTextRef.current.get(itemId);
1231
1229
  const finalText = displayedText || item.transcript || "";
1232
1230
  const stripped = finalText.replace(/[\s.…]+/g, "");
1233
1231
  if (stripped.length > 0) {
1234
1232
  updateTranscriptMessage(itemId, finalText, false);
1235
1233
  }
1236
1234
  updateTranscriptItem(itemId, { status: "DONE" });
1237
- const transcriptItem = transcriptItems.find((i) => i.itemId === itemId);
1235
+ const transcriptItem = transcriptItemsRef.current.find((i) => i.itemId === itemId);
1238
1236
  if (transcriptItem?.guardrailResult?.status === "IN_PROGRESS") {
1239
1237
  updateTranscriptItem(itemId, {
1240
1238
  guardrailResult: {
@@ -1259,7 +1257,8 @@ function useSessionHistory() {
1259
1257
  const category = moderation.moderationCategory ?? "NONE";
1260
1258
  const rationale = moderation.moderationRationale ?? "";
1261
1259
  const offendingText = moderation.testText;
1262
- updateTranscriptItem(lastAssistant.itemId, {
1260
+ const assistantItemId = lastAssistant.itemId ?? lastAssistant.id;
1261
+ updateTranscriptItem(assistantItemId, {
1263
1262
  guardrailResult: {
1264
1263
  status: "DONE",
1265
1264
  category,
@@ -1326,13 +1325,15 @@ function useRealtimeSession(callbacks = {}) {
1326
1325
  const [status, setStatus] = (0, import_react8.useState)("DISCONNECTED");
1327
1326
  const { logClientEvent, logServerEvent } = useEvent();
1328
1327
  const codecParamRef = (0, import_react8.useRef)("opus");
1328
+ const callbacksRef = (0, import_react8.useRef)(callbacks);
1329
+ callbacksRef.current = callbacks;
1329
1330
  const updateStatus = (0, import_react8.useCallback)(
1330
1331
  (s) => {
1331
1332
  setStatus(s);
1332
- callbacks.onConnectionChange?.(s);
1333
+ callbacksRef.current.onConnectionChange?.(s);
1333
1334
  logClientEvent({}, s);
1334
1335
  },
1335
- [callbacks, logClientEvent]
1336
+ [logClientEvent]
1336
1337
  );
1337
1338
  const historyHandlers = useSessionHistory().current;
1338
1339
  const interruptedRef = (0, import_react8.useRef)(/* @__PURE__ */ new Set());
@@ -1392,7 +1393,7 @@ function useRealtimeSession(callbacks = {}) {
1392
1393
  );
1393
1394
  });
1394
1395
  session.on("agent_handoff", (_from, to) => {
1395
- callbacks.onAgentHandoff?.(to);
1396
+ callbacksRef.current.onAgentHandoff?.(to);
1396
1397
  });
1397
1398
  session.on("guardrail_tripped", (info) => {
1398
1399
  historyHandlers.handleGuardrailTripped(
@@ -1429,7 +1430,7 @@ function useRealtimeSession(callbacks = {}) {
1429
1430
  console.error("Session error:", msg);
1430
1431
  logServerEvent({ type: "error", message: msg });
1431
1432
  });
1432
- }, [callbacks, historyHandlers, logServerEvent]);
1433
+ }, [historyHandlers, logServerEvent]);
1433
1434
  const connect = (0, import_react8.useCallback)(
1434
1435
  async ({
1435
1436
  getEphemeralKey,
@@ -1445,6 +1446,9 @@ function useRealtimeSession(callbacks = {}) {
1445
1446
  "useRealtimeSession: `adapter` is required in ConnectOptions. Pass an adapter like openai() from @jchaffin/voicekit/openai."
1446
1447
  );
1447
1448
  }
1449
+ if (!initialAgents?.length) {
1450
+ throw new Error("useRealtimeSession: `initialAgents` must be a non-empty array.");
1451
+ }
1448
1452
  updateStatus("CONNECTING");
1449
1453
  const ek = await getEphemeralKey();
1450
1454
  const rootAgent = initialAgents[0];
@@ -1667,8 +1671,8 @@ function SuggestionProvider({
1667
1671
  (0, import_react9.useEffect)(() => {
1668
1672
  const handler = (e) => {
1669
1673
  const detail = e.detail;
1670
- if (detail?.group) {
1671
- setSuggestionsState(detail.group);
1674
+ if (detail) {
1675
+ setSuggestionsState(detail.group ?? null);
1672
1676
  }
1673
1677
  };
1674
1678
  window.addEventListener(SUGGESTION_EVENT, handler);
package/dist/index.mjs CHANGED
@@ -159,6 +159,7 @@ function VoiceProvider({
159
159
  }, 500);
160
160
  } catch (error) {
161
161
  console.error("VoiceKit connection failed:", error);
162
+ sessionRef.current = null;
162
163
  onError?.(error instanceof Error ? error : new Error(String(error)));
163
164
  updateStatus("DISCONNECTED");
164
165
  }
@@ -604,6 +605,7 @@ function useAudioRecorder() {
604
605
  if (mediaRecorderRef.current?.state === "recording") {
605
606
  return;
606
607
  }
608
+ recordedChunksRef.current = [];
607
609
  try {
608
610
  const mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
609
611
  mediaRecorder.ondataavailable = (event) => {
@@ -958,11 +960,7 @@ function useSessionHistory() {
958
960
  const { itemId, role, content = [] } = item;
959
961
  if (itemId && role) {
960
962
  let text = extractMessageText(content);
961
- if (role === "assistant" && !text) {
962
- text = "";
963
- } else if (role === "user" && !text) {
964
- return;
965
- }
963
+ if (!text) text = "";
966
964
  const guardrailMessage = sketchilyDetectGuardrailMessage(text);
967
965
  if (guardrailMessage) {
968
966
  const failureDetails = JSON.parse(guardrailMessage);
@@ -1005,6 +1003,7 @@ function useSessionHistory() {
1005
1003
  const itemId = item.item_id;
1006
1004
  if (interruptedItemsRef.current.has(itemId)) return;
1007
1005
  if (itemId) {
1006
+ const displayedText = displayedTextRef.current.get(itemId) ?? accumulatedTextRef.current.get(itemId);
1008
1007
  const timer = deltaTimerRef.current.get(itemId);
1009
1008
  if (timer) clearTimeout(timer);
1010
1009
  deltaTimerRef.current.delete(itemId);
@@ -1013,14 +1012,13 @@ function useSessionHistory() {
1013
1012
  displayedTextRef.current.delete(itemId);
1014
1013
  accumulatedTextRef.current.delete(itemId);
1015
1014
  totalAudioDurationRef.current.delete(itemId);
1016
- const displayedText = displayedTextRef.current.get(itemId);
1017
1015
  const finalText = displayedText || item.transcript || "";
1018
1016
  const stripped = finalText.replace(/[\s.…]+/g, "");
1019
1017
  if (stripped.length > 0) {
1020
1018
  updateTranscriptMessage(itemId, finalText, false);
1021
1019
  }
1022
1020
  updateTranscriptItem(itemId, { status: "DONE" });
1023
- const transcriptItem = transcriptItems.find((i) => i.itemId === itemId);
1021
+ const transcriptItem = transcriptItemsRef.current.find((i) => i.itemId === itemId);
1024
1022
  if (transcriptItem?.guardrailResult?.status === "IN_PROGRESS") {
1025
1023
  updateTranscriptItem(itemId, {
1026
1024
  guardrailResult: {
@@ -1045,7 +1043,8 @@ function useSessionHistory() {
1045
1043
  const category = moderation.moderationCategory ?? "NONE";
1046
1044
  const rationale = moderation.moderationRationale ?? "";
1047
1045
  const offendingText = moderation.testText;
1048
- updateTranscriptItem(lastAssistant.itemId, {
1046
+ const assistantItemId = lastAssistant.itemId ?? lastAssistant.id;
1047
+ updateTranscriptItem(assistantItemId, {
1049
1048
  guardrailResult: {
1050
1049
  status: "DONE",
1051
1050
  category,
@@ -1112,13 +1111,15 @@ function useRealtimeSession(callbacks = {}) {
1112
1111
  const [status, setStatus] = useState5("DISCONNECTED");
1113
1112
  const { logClientEvent, logServerEvent } = useEvent();
1114
1113
  const codecParamRef = useRef6("opus");
1114
+ const callbacksRef = useRef6(callbacks);
1115
+ callbacksRef.current = callbacks;
1115
1116
  const updateStatus = useCallback6(
1116
1117
  (s) => {
1117
1118
  setStatus(s);
1118
- callbacks.onConnectionChange?.(s);
1119
+ callbacksRef.current.onConnectionChange?.(s);
1119
1120
  logClientEvent({}, s);
1120
1121
  },
1121
- [callbacks, logClientEvent]
1122
+ [logClientEvent]
1122
1123
  );
1123
1124
  const historyHandlers = useSessionHistory().current;
1124
1125
  const interruptedRef = useRef6(/* @__PURE__ */ new Set());
@@ -1178,7 +1179,7 @@ function useRealtimeSession(callbacks = {}) {
1178
1179
  );
1179
1180
  });
1180
1181
  session.on("agent_handoff", (_from, to) => {
1181
- callbacks.onAgentHandoff?.(to);
1182
+ callbacksRef.current.onAgentHandoff?.(to);
1182
1183
  });
1183
1184
  session.on("guardrail_tripped", (info) => {
1184
1185
  historyHandlers.handleGuardrailTripped(
@@ -1215,7 +1216,7 @@ function useRealtimeSession(callbacks = {}) {
1215
1216
  console.error("Session error:", msg);
1216
1217
  logServerEvent({ type: "error", message: msg });
1217
1218
  });
1218
- }, [callbacks, historyHandlers, logServerEvent]);
1219
+ }, [historyHandlers, logServerEvent]);
1219
1220
  const connect = useCallback6(
1220
1221
  async ({
1221
1222
  getEphemeralKey,
@@ -1231,6 +1232,9 @@ function useRealtimeSession(callbacks = {}) {
1231
1232
  "useRealtimeSession: `adapter` is required in ConnectOptions. Pass an adapter like openai() from @jchaffin/voicekit/openai."
1232
1233
  );
1233
1234
  }
1235
+ if (!initialAgents?.length) {
1236
+ throw new Error("useRealtimeSession: `initialAgents` must be a non-empty array.");
1237
+ }
1234
1238
  updateStatus("CONNECTING");
1235
1239
  const ek = await getEphemeralKey();
1236
1240
  const rootAgent = initialAgents[0];
@@ -1453,8 +1457,8 @@ function SuggestionProvider({
1453
1457
  useEffect6(() => {
1454
1458
  const handler = (e) => {
1455
1459
  const detail = e.detail;
1456
- if (detail?.group) {
1457
- setSuggestionsState(detail.group);
1460
+ if (detail) {
1461
+ setSuggestionsState(detail.group ?? null);
1458
1462
  }
1459
1463
  };
1460
1464
  window.addEventListener(SUGGESTION_EVENT, handler);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jchaffin/voicekit",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A provider-agnostic React library for building voice-enabled AI agents",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",