@cartesia/cartesia-js 0.0.1 → 0.0.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.
@@ -1,42 +1,58 @@
1
1
  import {
2
2
  audio_default
3
- } from "../chunk-5RMUZJV7.mjs";
4
- import "../chunk-BTFHUVNH.mjs";
5
- import "../chunk-ERFCRIWU.mjs";
3
+ } from "../chunk-3CYTAFLF.mjs";
6
4
  import {
5
+ base64ToArray,
6
+ bufferToWav
7
+ } from "../chunk-FRIBQZPN.mjs";
8
+ import "../chunk-XSFPHPPG.mjs";
9
+ import {
10
+ SAMPLE_RATE,
7
11
  __async
8
- } from "../chunk-35HX6ML3.mjs";
12
+ } from "../chunk-HNLIBHEN.mjs";
9
13
 
10
14
  // src/react/index.ts
11
15
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
16
  function useAudio({ apiKey, baseUrl }) {
13
- if (typeof window === "undefined" || !apiKey) {
17
+ if (typeof window === "undefined") {
14
18
  return {
15
19
  stream: () => {
16
20
  },
17
21
  play: () => __async(this, null, function* () {
18
22
  }),
23
+ download: () => null,
24
+ isConnected: false,
19
25
  isPlaying: false,
26
+ isStreamed: false,
20
27
  chunks: [],
21
28
  messages: []
22
29
  };
23
30
  }
24
31
  const audio = useMemo(() => {
32
+ if (!apiKey) {
33
+ return null;
34
+ }
25
35
  const audio2 = new audio_default({ apiKey, baseUrl });
26
36
  return audio2;
27
37
  }, [apiKey, baseUrl]);
28
38
  const streamReturn = useRef(null);
39
+ const [isStreamed, setIsStreamed] = useState(false);
29
40
  const [isPlaying, setIsPlaying] = useState(false);
41
+ const [isConnected, setIsConnected] = useState(false);
30
42
  const [chunks, setChunks] = useState([]);
31
43
  const [messages, setMessages] = useState([]);
32
44
  const stream = useCallback(
33
- (options) => {
45
+ (options) => __async(this, null, function* () {
34
46
  var _a;
35
47
  streamReturn.current = (_a = audio == null ? void 0 : audio.stream(options)) != null ? _a : null;
48
+ if (!streamReturn.current) {
49
+ return;
50
+ }
51
+ setMessages([]);
36
52
  streamReturn.current.on(
37
53
  "chunk",
38
- ({ chunks: chunks2 }) => {
39
- setChunks(chunks2);
54
+ ({ chunks: chunks3 }) => {
55
+ setChunks(chunks3);
40
56
  }
41
57
  );
42
58
  streamReturn.current.on(
@@ -45,23 +61,46 @@ function useAudio({ apiKey, baseUrl }) {
45
61
  setMessages((messages2) => [...messages2, message]);
46
62
  }
47
63
  );
48
- },
64
+ const { chunks: chunks2 } = yield streamReturn.current.once("streamed");
65
+ setChunks(chunks2);
66
+ setIsStreamed(true);
67
+ }),
49
68
  [audio]
50
69
  );
70
+ const download = useCallback(() => {
71
+ if (!isStreamed) {
72
+ return null;
73
+ }
74
+ const audio2 = bufferToWav(SAMPLE_RATE, [base64ToArray(chunks)]);
75
+ return new Blob([audio2], { type: "audio/wav" });
76
+ }, [isStreamed, chunks]);
51
77
  useEffect(() => {
52
- function initialize() {
78
+ let cleanup = () => {
79
+ };
80
+ function setupConnection() {
53
81
  return __async(this, null, function* () {
54
82
  try {
55
- yield audio == null ? void 0 : audio.connect();
83
+ const connection = yield audio == null ? void 0 : audio.connect();
84
+ if (!connection) {
85
+ return;
86
+ }
87
+ setIsConnected(true);
88
+ const unsubscribe = connection.on("close", () => {
89
+ setIsConnected(false);
90
+ });
91
+ return () => {
92
+ unsubscribe();
93
+ audio == null ? void 0 : audio.disconnect();
94
+ };
56
95
  } catch (e) {
57
96
  console.error(e);
58
97
  }
59
- return () => {
60
- audio == null ? void 0 : audio.disconnect();
61
- };
62
98
  });
63
99
  }
64
- initialize();
100
+ setupConnection().then((cleanupConnection) => {
101
+ cleanup = cleanupConnection;
102
+ });
103
+ return () => cleanup == null ? void 0 : cleanup();
65
104
  }, [audio]);
66
105
  const play = useCallback(
67
106
  (bufferDuration = 0) => __async(this, null, function* () {
@@ -75,7 +114,16 @@ function useAudio({ apiKey, baseUrl }) {
75
114
  }),
76
115
  [isPlaying]
77
116
  );
78
- return { stream, play, isPlaying, chunks, messages };
117
+ return {
118
+ stream,
119
+ play,
120
+ download,
121
+ isPlaying,
122
+ isConnected,
123
+ isStreamed,
124
+ chunks,
125
+ messages
126
+ };
79
127
  }
80
128
  export {
81
129
  useAudio
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "name": "Cartesia",
5
5
  "url": "https://cartesia.ai"
6
6
  },
7
- "version": "0.0.1",
7
+ "version": "0.0.2",
8
8
  "description": "Client for the Cartesia API.",
9
9
  "main": "./dist/index.js",
10
10
  "module": "./dist/index.mjs",
@@ -22,13 +22,14 @@
22
22
  "dependencies": {
23
23
  "base64-js": "^1.5.1",
24
24
  "emittery": "^1.0.3",
25
- "human-id": "^4.1.1"
25
+ "human-id": "^4.1.1",
26
+ "partysocket": "^1.0.1"
26
27
  },
27
28
  "publishConfig": {
28
- "access": "restricted"
29
+ "access": "public"
29
30
  },
30
31
  "scripts": {
31
- "build": "tsup src/ --format cjs,esm --dts --clean",
32
+ "build": "tsup src/ --format cjs,esm --dts",
32
33
  "dev": "bun run build -- --watch"
33
34
  },
34
35
  "peerDependencies": {
@@ -1,11 +1,14 @@
1
1
  import Emittery from "emittery";
2
2
  import { humanId } from "human-id";
3
+ import { WebSocket } from "partysocket";
3
4
  import { Client } from "../lib/client";
4
5
  import { SAMPLE_RATE, constructWebsocketUrl } from "../lib/constants";
5
6
  import {
7
+ type EmitteryCallbacks,
6
8
  type Sentinel,
7
9
  createMessageHandlerForContextId,
8
10
  getBufferDuration,
11
+ getEmitteryCallbacks,
9
12
  isComplete,
10
13
  isSentinel,
11
14
  playAudioBuffer,
@@ -17,8 +20,22 @@ export type StreamEventData = {
17
20
  chunk: Chunk;
18
21
  chunks: Chunk[];
19
22
  };
23
+ streamed: {
24
+ chunks: Chunk[];
25
+ };
20
26
  message: unknown;
21
27
  };
28
+ export type ConnectionEventData = {
29
+ open: never;
30
+ close: never;
31
+ };
32
+ export type StreamRequest = {
33
+ inputs: object;
34
+ options: {
35
+ timeout?: number;
36
+ };
37
+ };
38
+
22
39
  export default class extends Client {
23
40
  socket?: WebSocket;
24
41
  isConnected = false;
@@ -35,7 +52,10 @@ export default class extends Client {
35
52
  * that plays the audio as it arrives, with `bufferDuration` seconds of audio buffered before
36
53
  * starting playback.
37
54
  */
38
- stream(inputs: object, { timeout = 0 }: { timeout?: number } = {}) {
55
+ stream(
56
+ inputs: StreamRequest["inputs"],
57
+ { timeout = 0 }: StreamRequest["options"] = {},
58
+ ) {
39
59
  if (!this.isConnected) {
40
60
  throw new Error("Not connected to WebSocket. Call .connect() first.");
41
61
  }
@@ -72,6 +92,9 @@ export default class extends Client {
72
92
  });
73
93
  await emitter.emit("message", message);
74
94
  if (isSentinel(chunk)) {
95
+ await emitter.emit("streamed", {
96
+ chunks,
97
+ });
75
98
  streamCompleteController.abort();
76
99
  } else if (timeoutId) {
77
100
  clearTimeout(timeoutId);
@@ -82,13 +105,28 @@ export default class extends Client {
82
105
  this.socket?.addEventListener("message", handleMessage, {
83
106
  signal: streamCompleteController.signal,
84
107
  });
85
- this.socket?.addEventListener("close", streamCompleteController.abort, {
86
- once: true,
87
- });
88
- this.socket?.addEventListener("error", streamCompleteController.abort, {
89
- once: true,
90
- });
108
+ this.socket?.addEventListener(
109
+ "close",
110
+ () => {
111
+ streamCompleteController.abort();
112
+ },
113
+ {
114
+ once: true,
115
+ },
116
+ );
117
+ this.socket?.addEventListener(
118
+ "error",
119
+ () => {
120
+ streamCompleteController.abort();
121
+ },
122
+ {
123
+ once: true,
124
+ },
125
+ );
91
126
  streamCompleteController.signal.addEventListener("abort", () => {
127
+ if (timeoutId) {
128
+ clearTimeout(timeoutId);
129
+ }
92
130
  emitter.clearListeners();
93
131
  });
94
132
 
@@ -158,10 +196,7 @@ export default class extends Client {
158
196
 
159
197
  return {
160
198
  play,
161
- on: emitter.on.bind(emitter),
162
- off: emitter.off.bind(emitter),
163
- once: emitter.once.bind(emitter),
164
- events: emitter.events.bind(emitter),
199
+ ...getEmitteryCallbacks(emitter),
165
200
  };
166
201
  }
167
202
 
@@ -189,48 +224,53 @@ export default class extends Client {
189
224
  connect() {
190
225
  const url = constructWebsocketUrl(this.baseUrl);
191
226
  url.searchParams.set("api_key", this.apiKey);
192
- this.socket = new WebSocket(url);
227
+ const emitter = new Emittery<ConnectionEventData>();
228
+ this.socket = new WebSocket(url.toString());
193
229
  this.socket.onopen = () => {
194
230
  this.isConnected = true;
231
+ emitter.emit("open");
195
232
  };
196
233
  this.socket.onclose = () => {
197
234
  this.isConnected = false;
235
+ emitter.emit("close");
198
236
  };
199
237
 
200
- return new Promise<void>((resolve, reject) => {
201
- this.socket?.addEventListener(
202
- "open",
203
- () => {
204
- resolve();
205
- },
206
- {
207
- once: true,
208
- },
209
- );
210
-
211
- const aborter = new AbortController();
212
- this.socket?.addEventListener(
213
- "error",
214
- () => {
215
- aborter.abort();
216
- reject(new Error("WebSocket failed to connect."));
217
- },
218
- {
219
- signal: aborter.signal,
220
- },
221
- );
222
-
223
- this.socket?.addEventListener(
224
- "close",
225
- () => {
226
- aborter.abort();
227
- reject(new Error("WebSocket closed before it could connect."));
228
- },
229
- {
230
- signal: aborter.signal,
231
- },
232
- );
233
- });
238
+ return new Promise<EmitteryCallbacks<ConnectionEventData>>(
239
+ (resolve, reject) => {
240
+ this.socket?.addEventListener(
241
+ "open",
242
+ () => {
243
+ resolve(getEmitteryCallbacks(emitter));
244
+ },
245
+ {
246
+ once: true,
247
+ },
248
+ );
249
+
250
+ const aborter = new AbortController();
251
+ this.socket?.addEventListener(
252
+ "error",
253
+ () => {
254
+ aborter.abort();
255
+ reject(new Error("WebSocket failed to connect."));
256
+ },
257
+ {
258
+ signal: aborter.signal,
259
+ },
260
+ );
261
+
262
+ this.socket?.addEventListener(
263
+ "close",
264
+ () => {
265
+ aborter.abort();
266
+ reject(new Error("WebSocket closed before it could connect."));
267
+ },
268
+ {
269
+ signal: aborter.signal,
270
+ },
271
+ );
272
+ },
273
+ );
234
274
  }
235
275
 
236
276
  /**
@@ -1,4 +1,5 @@
1
1
  import base64 from "base64-js";
2
+ import type Emittery from "emittery";
2
3
  import type { Chunk, StreamEventData } from ".";
3
4
  import { SAMPLE_RATE } from "../lib/constants";
4
5
 
@@ -136,3 +137,84 @@ export function filterSentinel<T>(collection: T[]): Exclude<T, Sentinel>[] {
136
137
  export function isComplete(chunks: Chunk[]) {
137
138
  return isSentinel(chunks[chunks.length - 1]);
138
139
  }
140
+
141
+ export type EmitteryCallbacks<T> = {
142
+ on: Emittery<T>["on"];
143
+ off: Emittery<T>["off"];
144
+ once: Emittery<T>["once"];
145
+ events: Emittery<T>["events"];
146
+ };
147
+ /**
148
+ * Get user-facing emitter callbacks for an Emittery instance.
149
+ * @param emitter The Emittery instance to get callbacks for.
150
+ * @returns User-facing emitter callbacks.
151
+ */
152
+ export function getEmitteryCallbacks<T>(
153
+ emitter: Emittery<T>,
154
+ ): EmitteryCallbacks<T> {
155
+ return {
156
+ on: emitter.on.bind(emitter),
157
+ off: emitter.off.bind(emitter),
158
+ once: emitter.once.bind(emitter),
159
+ events: emitter.events.bind(emitter),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Converts a base64-encoded audio buffer to a WAV file.
165
+ * Source: https://gist.github.com/Daninet/22edc59cf2aee0b9a90c18e553e49297
166
+ * @param b64 The base64-encoded audio buffer to convert to a WAV file.
167
+ */
168
+ export function bufferToWav(
169
+ sampleRate: number,
170
+ channelBuffers: Float32Array[],
171
+ ) {
172
+ const totalSamples = channelBuffers[0].length * channelBuffers.length;
173
+
174
+ const buffer = new ArrayBuffer(44 + totalSamples * 2);
175
+ const view = new DataView(buffer);
176
+
177
+ const writeString = (view: DataView, offset: number, string: string) => {
178
+ for (let i = 0; i < string.length; i++) {
179
+ view.setUint8(offset + i, string.charCodeAt(i));
180
+ }
181
+ };
182
+
183
+ /* RIFF identifier */
184
+ writeString(view, 0, "RIFF");
185
+ /* RIFF chunk length */
186
+ view.setUint32(4, 36 + totalSamples * 2, true);
187
+ /* RIFF type */
188
+ writeString(view, 8, "WAVE");
189
+ /* format chunk identifier */
190
+ writeString(view, 12, "fmt ");
191
+ /* format chunk length */
192
+ view.setUint32(16, 16, true);
193
+ /* sample format (raw) */
194
+ view.setUint16(20, 1, true);
195
+ /* channel count */
196
+ view.setUint16(22, channelBuffers.length, true);
197
+ /* sample rate */
198
+ view.setUint32(24, sampleRate, true);
199
+ /* byte rate (sample rate * block align) */
200
+ view.setUint32(28, sampleRate * 4, true);
201
+ /* block align (channel count * bytes per sample) */
202
+ view.setUint16(32, channelBuffers.length * 2, true);
203
+ /* bits per sample */
204
+ view.setUint16(34, 16, true);
205
+ /* data chunk identifier */
206
+ writeString(view, 36, "data");
207
+ /* data chunk length */
208
+ view.setUint32(40, totalSamples * 2, true);
209
+
210
+ let offset = 44;
211
+ for (let i = 0; i < channelBuffers[0].length; i++) {
212
+ for (let channel = 0; channel < channelBuffers.length; channel++) {
213
+ const s = Math.max(-1, Math.min(1, channelBuffers[channel][i]));
214
+ view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
215
+ offset += 2;
216
+ }
217
+ }
218
+
219
+ return buffer;
220
+ }
@@ -2,5 +2,5 @@ export const BASE_URL = "https://api.cartesia.ai/v0";
2
2
  export const SAMPLE_RATE = 44100;
3
3
 
4
4
  export const constructWebsocketUrl = (baseUrl: string) => {
5
- return new URL(`${baseUrl.replace(/^http/, "ws")}/ws`);
5
+ return new URL(`${baseUrl.replace(/^http/, "ws")}/audio/websocket`);
6
6
  };
@@ -1,15 +1,20 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import CartesiaAudio, { type Chunk, type StreamEventData } from "../audio";
3
+ import { base64ToArray, bufferToWav } from "../audio/utils";
4
+ import { SAMPLE_RATE } from "../lib/constants";
3
5
 
4
- interface UseAudioOptions {
5
- apiKey: string;
6
+ export type UseAudioOptions = {
7
+ apiKey: string | null;
6
8
  baseUrl?: string;
7
- }
9
+ };
8
10
 
9
11
  interface UseAudioReturn {
10
12
  stream: (options: object) => void;
11
13
  play: (bufferDuration?: number) => Promise<void>;
14
+ download: () => Blob | null;
12
15
  isPlaying: boolean;
16
+ isConnected: boolean;
17
+ isStreamed: boolean;
13
18
  chunks: Chunk[];
14
19
  messages: StreamEventData["message"][];
15
20
  }
@@ -17,28 +22,40 @@ interface UseAudioReturn {
17
22
  * React hook to use the Cartesia audio API.
18
23
  */
19
24
  export function useAudio({ apiKey, baseUrl }: UseAudioOptions): UseAudioReturn {
20
- if (typeof window === "undefined" || !apiKey) {
25
+ if (typeof window === "undefined") {
21
26
  return {
22
27
  stream: () => {},
23
28
  play: async () => {},
29
+ download: () => null,
30
+ isConnected: false,
24
31
  isPlaying: false,
32
+ isStreamed: false,
25
33
  chunks: [],
26
34
  messages: [],
27
35
  };
28
36
  }
29
37
 
30
38
  const audio = useMemo(() => {
39
+ if (!apiKey) {
40
+ return null;
41
+ }
31
42
  const audio = new CartesiaAudio({ apiKey, baseUrl });
32
43
  return audio;
33
44
  }, [apiKey, baseUrl]);
34
45
  const streamReturn = useRef<ReturnType<CartesiaAudio["stream"]> | null>(null);
46
+ const [isStreamed, setIsStreamed] = useState(false);
35
47
  const [isPlaying, setIsPlaying] = useState(false);
48
+ const [isConnected, setIsConnected] = useState(false);
36
49
  const [chunks, setChunks] = useState<Chunk[]>([]);
37
50
  const [messages, setMessages] = useState<StreamEventData["message"][]>([]);
38
51
 
39
52
  const stream = useCallback(
40
- (options: object) => {
53
+ async (options: object) => {
41
54
  streamReturn.current = audio?.stream(options) ?? null;
55
+ if (!streamReturn.current) {
56
+ return;
57
+ }
58
+ setMessages([]);
42
59
  streamReturn.current.on(
43
60
  "chunk",
44
61
  ({ chunks }: StreamEventData["chunk"]) => {
@@ -51,22 +68,45 @@ export function useAudio({ apiKey, baseUrl }: UseAudioOptions): UseAudioReturn {
51
68
  setMessages((messages) => [...messages, message]);
52
69
  },
53
70
  );
71
+ const { chunks } = await streamReturn.current.once("streamed");
72
+ setChunks(chunks);
73
+ setIsStreamed(true);
54
74
  },
55
75
  [audio],
56
76
  );
57
77
 
78
+ const download = useCallback(() => {
79
+ if (!isStreamed) {
80
+ return null;
81
+ }
82
+ const audio = bufferToWav(SAMPLE_RATE, [base64ToArray(chunks)]);
83
+ return new Blob([audio], { type: "audio/wav" });
84
+ }, [isStreamed, chunks]);
85
+
58
86
  useEffect(() => {
59
- async function initialize() {
87
+ let cleanup: (() => void) | undefined = () => {};
88
+ async function setupConnection() {
60
89
  try {
61
- await audio?.connect();
90
+ const connection = await audio?.connect();
91
+ if (!connection) {
92
+ return;
93
+ }
94
+ setIsConnected(true);
95
+ const unsubscribe = connection.on("close", () => {
96
+ setIsConnected(false);
97
+ });
98
+ return () => {
99
+ unsubscribe();
100
+ audio?.disconnect();
101
+ };
62
102
  } catch (e) {
63
103
  console.error(e);
64
104
  }
65
- return () => {
66
- audio?.disconnect();
67
- };
68
105
  }
69
- initialize();
106
+ setupConnection().then((cleanupConnection) => {
107
+ cleanup = cleanupConnection;
108
+ });
109
+ return () => cleanup?.();
70
110
  }, [audio]);
71
111
 
72
112
  const play = useCallback(
@@ -87,5 +127,14 @@ export function useAudio({ apiKey, baseUrl }: UseAudioOptions): UseAudioReturn {
87
127
  // - [] Seek to a specific time.
88
128
  // These are probably best implemented by adding event listener
89
129
  // functionality to the base library.
90
- return { stream, play, isPlaying, chunks, messages };
130
+ return {
131
+ stream,
132
+ play,
133
+ download,
134
+ isPlaying,
135
+ isConnected,
136
+ isStreamed,
137
+ chunks,
138
+ messages,
139
+ };
91
140
  }