@micdrop/server 1.6.0 → 1.7.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
@@ -30,7 +30,7 @@ pnpm add @micdrop/server
30
30
 
31
31
  ```typescript
32
32
  import { WebSocketServer } from 'ws'
33
- import { CallSocket, CallConfig } from '@micdrop/server'
33
+ import { CallServer, CallConfig } from '@micdrop/server'
34
34
 
35
35
  // Create WebSocket server
36
36
  const wss = new WebSocketServer({ port: 8080 })
@@ -79,7 +79,7 @@ const config: CallConfig = {
79
79
  // Handle new connections
80
80
  wss.on('connection', (ws) => {
81
81
  // Create call handler with configuration
82
- new CallSocket(ws, config)
82
+ new CallServer(ws, config)
83
83
  })
84
84
  ```
85
85
 
@@ -88,7 +88,7 @@ wss.on('connection', (ws) => {
88
88
  Check out the demo implementation in the [@micdrop/demo-server](../demo-server/README.md) package. It shows:
89
89
 
90
90
  - Setting up a Fastify server with WebSocket support
91
- - Configuring the CallSocket with custom handlers
91
+ - Configuring the CallServer with custom handlers
92
92
  - Basic authentication flow
93
93
  - Example speech-to-text and text-to-speech implementations
94
94
  - Error handling patterns
@@ -99,19 +99,19 @@ Here's a simplified version from the demo:
99
99
 
100
100
  The server package provides several core components:
101
101
 
102
- - **CallSocket** - Main class that handles WebSocket connections, audio streaming, and conversation flow
102
+ - **CallServer** - Main class that handles WebSocket connections, audio streaming, and conversation flow
103
103
  - **CallConfig** - Configuration interface for customizing speech processing and conversation behavior
104
104
  - **Types** - Common TypeScript types and interfaces for messages and commands
105
105
  - **Error Handling** - Standardized error handling with specific error codes
106
106
 
107
107
  ## API Reference
108
108
 
109
- ### CallSocket
109
+ ### CallServer
110
110
 
111
111
  The main class for managing WebSocket connections and audio streaming.
112
112
 
113
113
  ```typescript
114
- class CallSocket {
114
+ class CallServer {
115
115
  constructor(socket: WebSocket, config: CallConfig)
116
116
 
117
117
  // Add assistant message and send to client with audio (TTS)
@@ -194,12 +194,28 @@ The server can send the following commands to the client:
194
194
 
195
195
  See detailed protocol in [README.md](../README.md).
196
196
 
197
+ ## Message metadata
198
+
199
+ You can add metadata to the generated answers, that will be accessible in the conversation on the client and server side.
200
+
201
+ ```typescript
202
+ const metadata: AnswerMetadata = {
203
+ // ...
204
+ }
205
+
206
+ const message: ConversationMessage = {
207
+ role: 'assistant',
208
+ content: 'Hello!',
209
+ metadata,
210
+ }
211
+ ```
212
+
197
213
  ## Ending the call
198
214
 
199
215
  The call has two ways to end:
200
216
 
201
217
  - When the client closes the websocket connection.
202
- - When the generated answer contains the metadata command `endCall: true`.
218
+ - When the generated answer contains the commands `endCall: true`.
203
219
 
204
220
  Example:
205
221
 
@@ -224,13 +240,13 @@ async function generateAnswer(
224
240
  if (!text) throw new Error('Empty response')
225
241
 
226
242
  // Add metadata
227
- const metadata: CallMetadata = {}
243
+ const commands: AnswerCommands = {}
228
244
  if (text.includes(END_CALL)) {
229
245
  text = text.replace(END_CALL, '').trim()
230
- metadata.commands = { endCall: true }
246
+ commands.endCall = true
231
247
  }
232
248
 
233
- return { role: 'assistant', content: text, metadata }
249
+ return { role: 'assistant', content: text, commands }
234
250
  }
235
251
  ```
236
252
 
@@ -243,7 +259,7 @@ Here's an example using Fastify:
243
259
  ```typescript
244
260
  import fastify from 'fastify'
245
261
  import fastifyWebsocket from '@fastify/websocket'
246
- import { CallSocket, CallConfig } from '@micdrop/server'
262
+ import { CallServer, CallConfig } from '@micdrop/server'
247
263
 
248
264
  const server = fastify()
249
265
  server.register(fastifyWebsocket)
@@ -253,7 +269,7 @@ server.get('/call', { websocket: true }, (socket) => {
253
269
  systemPrompt: 'You are a helpful assistant',
254
270
  // ... other config options
255
271
  }
256
- new CallSocket(socket, config)
272
+ new CallServer(socket, config)
257
273
  })
258
274
 
259
275
  server.listen({ port: 8080 })
package/dist/index.d.mts CHANGED
@@ -30,24 +30,25 @@ interface CallSummary {
30
30
  duration: number;
31
31
  }
32
32
  type Conversation = ConversationMessage[];
33
- type CallMetadata = {
34
- commands?: {
35
- endCall?: boolean;
36
- cancelLastUserMessage?: boolean;
37
- skipAnswer?: boolean;
38
- };
33
+ type AnswerCommands = {
34
+ endCall?: boolean;
35
+ cancelLastUserMessage?: boolean;
36
+ skipAnswer?: boolean;
37
+ };
38
+ type AnswerMetadata = {
39
39
  [key: string]: any;
40
40
  };
41
- interface ConversationMessage<Data extends CallMetadata = CallMetadata> {
41
+ interface ConversationMessage<Data extends AnswerMetadata = AnswerMetadata> {
42
42
  role: 'system' | 'user' | 'assistant';
43
43
  content: string;
44
+ commands?: AnswerCommands;
44
45
  metadata?: Data;
45
46
  }
46
47
 
47
48
  interface Processing {
48
49
  aborted: boolean;
49
50
  }
50
- declare class CallSocket {
51
+ declare class CallServer {
51
52
  socket: WebSocket | null;
52
53
  config: CallConfig | null;
53
54
  private startTime;
@@ -82,4 +83,4 @@ declare function handleError(socket: WebSocket$1, error: unknown): void;
82
83
 
83
84
  declare function waitForParams<CallParams>(socket: WebSocket, validate: (params: any) => CallParams): Promise<CallParams>;
84
85
 
85
- export { CallClientCommands, type CallConfig, CallError, CallErrorCode, type CallMetadata, CallServerCommands, CallSocket, type CallSummary, type Conversation, type ConversationMessage, handleError, waitForParams };
86
+ export { type AnswerCommands, type AnswerMetadata, CallClientCommands, type CallConfig, CallError, CallErrorCode, CallServer, CallServerCommands, type CallSummary, type Conversation, type ConversationMessage, handleError, waitForParams };
package/dist/index.d.ts CHANGED
@@ -30,24 +30,25 @@ interface CallSummary {
30
30
  duration: number;
31
31
  }
32
32
  type Conversation = ConversationMessage[];
33
- type CallMetadata = {
34
- commands?: {
35
- endCall?: boolean;
36
- cancelLastUserMessage?: boolean;
37
- skipAnswer?: boolean;
38
- };
33
+ type AnswerCommands = {
34
+ endCall?: boolean;
35
+ cancelLastUserMessage?: boolean;
36
+ skipAnswer?: boolean;
37
+ };
38
+ type AnswerMetadata = {
39
39
  [key: string]: any;
40
40
  };
41
- interface ConversationMessage<Data extends CallMetadata = CallMetadata> {
41
+ interface ConversationMessage<Data extends AnswerMetadata = AnswerMetadata> {
42
42
  role: 'system' | 'user' | 'assistant';
43
43
  content: string;
44
+ commands?: AnswerCommands;
44
45
  metadata?: Data;
45
46
  }
46
47
 
47
48
  interface Processing {
48
49
  aborted: boolean;
49
50
  }
50
- declare class CallSocket {
51
+ declare class CallServer {
51
52
  socket: WebSocket | null;
52
53
  config: CallConfig | null;
53
54
  private startTime;
@@ -82,4 +83,4 @@ declare function handleError(socket: WebSocket$1, error: unknown): void;
82
83
 
83
84
  declare function waitForParams<CallParams>(socket: WebSocket, validate: (params: any) => CallParams): Promise<CallParams>;
84
85
 
85
- export { CallClientCommands, type CallConfig, CallError, CallErrorCode, type CallMetadata, CallServerCommands, CallSocket, type CallSummary, type Conversation, type ConversationMessage, handleError, waitForParams };
86
+ export { type AnswerCommands, type AnswerMetadata, CallClientCommands, type CallConfig, CallError, CallErrorCode, CallServer, CallServerCommands, type CallSummary, type Conversation, type ConversationMessage, handleError, waitForParams };
package/dist/index.js CHANGED
@@ -33,14 +33,14 @@ __export(index_exports, {
33
33
  CallClientCommands: () => CallClientCommands,
34
34
  CallError: () => CallError,
35
35
  CallErrorCode: () => CallErrorCode,
36
+ CallServer: () => CallServer,
36
37
  CallServerCommands: () => CallServerCommands,
37
- CallSocket: () => CallSocket,
38
38
  handleError: () => handleError,
39
39
  waitForParams: () => waitForParams
40
40
  });
41
41
  module.exports = __toCommonJS(index_exports);
42
42
 
43
- // src/CallSocket.ts
43
+ // src/CallServer.ts
44
44
  var fs = __toESM(require("fs"));
45
45
 
46
46
  // src/types.ts
@@ -60,8 +60,8 @@ var CallServerCommands = /* @__PURE__ */ ((CallServerCommands2) => {
60
60
  return CallServerCommands2;
61
61
  })(CallServerCommands || {});
62
62
 
63
- // src/CallSocket.ts
64
- var CallSocket = class {
63
+ // src/CallServer.ts
64
+ var CallServer = class {
65
65
  constructor(socket, config) {
66
66
  this.socket = null;
67
67
  this.config = null;
@@ -81,7 +81,7 @@ var CallSocket = class {
81
81
  this.answer(config.firstMessage);
82
82
  } else {
83
83
  this.config.generateAnswer(this.conversation).then((answer) => this.answer(answer)).catch((error) => {
84
- console.error("[CallSocket]", error);
84
+ console.error("[CallServer]", error);
85
85
  socket?.close();
86
86
  });
87
87
  }
@@ -145,7 +145,7 @@ var CallSocket = class {
145
145
  }
146
146
  async onMessage(message) {
147
147
  if (!Buffer.isBuffer(message)) {
148
- console.warn(`[CallSocket] Message is not a buffer`);
148
+ console.warn(`[CallServer] Message is not a buffer`);
149
149
  return;
150
150
  }
151
151
  if (message.byteLength < 15) {
@@ -155,6 +155,8 @@ var CallSocket = class {
155
155
  this.isSpeaking = true;
156
156
  this.abortProcessing();
157
157
  } else if (cmd === "Mute" /* Mute */) {
158
+ this.isSpeaking = false;
159
+ this.chunks.length = 0;
158
160
  this.abortProcessing();
159
161
  } else if (cmd === "StopSpeaking" /* StopSpeaking */) {
160
162
  this.isSpeaking = false;
@@ -200,7 +202,7 @@ var CallSocket = class {
200
202
  }
201
203
  await this.answer(answer, processing);
202
204
  } catch (error) {
203
- console.error("[CallSocket]", error);
205
+ console.error("[CallServer]", error);
204
206
  this.socket?.send("SkipAnswer" /* SkipAnswer */);
205
207
  }
206
208
  }
@@ -211,9 +213,10 @@ var CallSocket = class {
211
213
  this.abortProcessing();
212
214
  processing = this.processing = { aborted: false };
213
215
  }
214
- let content = typeof message === "string" ? message : message.content;
215
- const metadata = typeof message === "string" ? void 0 : message.metadata;
216
- if (!content.length || metadata?.commands?.cancelLastUserMessage) {
216
+ if (typeof message === "string") {
217
+ message = { role: "assistant", content: message };
218
+ }
219
+ if (message.commands?.cancelLastUserMessage) {
217
220
  this.log("Cancelling last user message");
218
221
  const lastMessage = this.conversation[this.conversation.length - 1];
219
222
  if (lastMessage?.role === "user") {
@@ -222,13 +225,13 @@ var CallSocket = class {
222
225
  }
223
226
  return;
224
227
  }
225
- if (metadata?.commands?.skipAnswer) {
228
+ if (!message.content.length || message.commands?.skipAnswer) {
226
229
  this.log("Skipping answer");
227
230
  this.socket?.send("SkipAnswer" /* SkipAnswer */);
228
231
  return;
229
232
  }
230
233
  this.log("Assistant message:", message);
231
- this.addMessage({ role: "assistant", content, metadata });
234
+ this.addMessage(message);
232
235
  if (!this.config.disableTTS) {
233
236
  try {
234
237
  const onAbort = () => {
@@ -243,14 +246,14 @@ var CallSocket = class {
243
246
  onAbort();
244
247
  return;
245
248
  }
246
- const audio = await this.config.text2Speech(content);
249
+ const audio = await this.config.text2Speech(message.content);
247
250
  await this.sendAudio(audio, processing, onAbort);
248
251
  } catch (error) {
249
- console.error("[CallSocket]", error);
252
+ console.error("[CallServer]", error);
250
253
  this.socket?.send("SkipAnswer" /* SkipAnswer */);
251
254
  }
252
255
  }
253
- if (metadata?.commands?.endCall) {
256
+ if (message.commands?.endCall) {
254
257
  this.log("Call ended");
255
258
  this.socket.send("EndCall" /* EndCall */);
256
259
  }
@@ -311,8 +314,8 @@ async function waitForParams(socket, validate) {
311
314
  CallClientCommands,
312
315
  CallError,
313
316
  CallErrorCode,
317
+ CallServer,
314
318
  CallServerCommands,
315
- CallSocket,
316
319
  handleError,
317
320
  waitForParams
318
321
  });
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/CallSocket.ts","../src/types.ts","../src/errors.ts","../src/waitForParams.ts"],"sourcesContent":["export * from './CallSocket'\nexport * from './errors'\nexport * from './types'\nexport * from './waitForParams'\n","import * as fs from 'fs'\nimport { WebSocket } from 'ws'\nimport {\n CallClientCommands,\n CallConfig,\n CallServerCommands,\n Conversation,\n ConversationMessage,\n} from './types'\n\ninterface Processing {\n aborted: boolean\n}\n\nexport class CallSocket {\n public socket: WebSocket | null = null\n public config: CallConfig | null = null\n\n private startTime = Date.now()\n private lastDebug = Date.now()\n\n // An answer can be aborted if user is speaking\n private processing?: Processing\n\n // When user is speaking, we're waiting to chunks or to stop\n private isSpeaking = false\n\n // Chunks of user speech since user started speaking\n private chunks: Buffer[] = []\n\n // Conversation history\n private conversation: Conversation\n\n // Enable speaker streaming\n private speakerStreamingEnabled = false\n\n constructor(socket: WebSocket, config: CallConfig) {\n this.socket = socket\n this.config = config\n this.conversation = [{ role: 'system', content: config.systemPrompt }]\n this.log(`Call started`)\n\n // Assistant speaks first\n if (config.firstMessage) {\n this.answer(config.firstMessage)\n } else {\n this.config\n .generateAnswer(this.conversation)\n .then((answer) => this.answer(answer))\n .catch((error) => {\n console.error('[CallSocket]', error)\n socket?.close()\n // TODO: Implement retry\n })\n }\n\n // Listen to events\n socket.on('close', this.onClose.bind(this))\n socket.on('message', this.onMessage.bind(this))\n }\n\n // Reset conversation\n public resetConversation(conversation: Conversation) {\n this.log('Reset conversation')\n this.conversation = conversation\n }\n\n private abortProcessing() {\n if (!this.processing) return\n this.processing.aborted = true\n this.processing = undefined\n }\n\n private addMessage(message: ConversationMessage) {\n if (!this.socket || !this.config) return\n this.conversation.push(message)\n this.socket.send(`${CallServerCommands.Message} ${JSON.stringify(message)}`)\n this.config.onMessage?.(message)\n }\n\n private async sendAudio(\n audio: ArrayBuffer | NodeJS.ReadableStream,\n processing: Processing,\n onAbort?: () => void\n ) {\n if (!this.socket) return\n if (processing.aborted) {\n onAbort?.()\n return\n }\n\n if (Buffer.isBuffer(audio) || audio instanceof ArrayBuffer) {\n // Whole audio as a single buffer\n this.log(`Send audio: (${audio.byteLength} bytes)`)\n this.socket.send(audio)\n } else if ('paused' in audio) {\n // Enable speaker streaming if not already enabled\n if (!this.speakerStreamingEnabled) {\n this.socket.send(CallServerCommands.EnableSpeakerStreaming)\n this.speakerStreamingEnabled = true\n }\n\n // Audio as a stream\n for await (const chunk of audio) {\n if (processing.aborted) {\n onAbort?.()\n return\n }\n this.log(`Send audio chunk (${chunk.length} bytes)`)\n this.socket.send(chunk)\n }\n } else {\n this.log(`Unknown audio type: ${audio}`)\n }\n }\n\n private onClose() {\n if (!this.config) return\n this.log('Connection closed')\n this.abortProcessing()\n const duration = Math.round((Date.now() - this.startTime) / 1000)\n\n // End call callback\n this.config.onEnd?.({\n conversation: this.conversation.slice(1), // Remove system message\n duration,\n })\n\n // Unset params\n this.socket = null\n this.config = null\n }\n\n private async onMessage(message: Buffer) {\n if (!Buffer.isBuffer(message)) {\n console.warn(`[CallSocket] Message is not a buffer`)\n return\n }\n\n // Commands\n if (message.byteLength < 15) {\n const cmd = message.toString()\n this.log(`Command: ${cmd}`)\n\n if (cmd === CallClientCommands.StartSpeaking) {\n // User started speaking\n this.isSpeaking = true\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.Mute) {\n // User muted the call\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.StopSpeaking) {\n // User stopped speaking\n this.isSpeaking = false\n await this.onStopSpeaking()\n }\n }\n\n // Audio chunk\n else if (Buffer.isBuffer(message) && this.isSpeaking) {\n this.log(`Received chunk (${message.byteLength} bytes)`)\n this.chunks.push(message)\n }\n }\n\n private async onStopSpeaking() {\n if (!this.config) return\n\n // Do nothing if there is no chunk\n if (this.chunks.length === 0) return\n\n this.abortProcessing()\n const processing = (this.processing = { aborted: false })\n\n // Combine audio blob\n const blob = new Blob(this.chunks, { type: 'audio/ogg' })\n\n // Reset chunks for next user speech\n this.chunks.length = 0\n\n try {\n // Save file to disk\n if (this.config.debugSaveSpeech) {\n const filename = `speech-${Date.now()}.ogg`\n fs.writeFileSync(filename, Buffer.from(await blob.arrayBuffer()))\n this.log(`Saved speech: ${filename}`)\n }\n\n // STT: Get transcript and send to client\n const transcript = await this.config.speech2Text(\n blob,\n this.conversation[this.conversation.length - 1]?.content\n )\n if (!transcript) {\n this.log('Ignoring empty transcript')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n this.log('User transcript:', transcript)\n\n // Send transcript to client\n this.addMessage({ role: 'user', content: transcript })\n\n if (processing.aborted) {\n this.log('Answer aborted, no answer generated')\n return\n }\n\n // LLM: Generate answer\n const answer = await this.config.generateAnswer(this.conversation)\n if (processing.aborted) {\n this.log('Answer aborted, ignoring answer')\n return\n }\n\n await this.answer(answer, processing)\n } catch (error) {\n console.error('[CallSocket]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // Add assistant message and send to client with audio (TTS)\n public async answer(\n message: string | ConversationMessage,\n processing?: Processing\n ) {\n if (!this.socket || !this.config) return\n\n if (!processing) {\n this.abortProcessing()\n processing = this.processing = { aborted: false }\n }\n\n let content = typeof message === 'string' ? message : message.content\n const metadata = typeof message === 'string' ? undefined : message.metadata\n\n // Cancel last user message\n if (!content.length || metadata?.commands?.cancelLastUserMessage) {\n this.log('Cancelling last user message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'user') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastUserMessage)\n }\n return\n }\n\n // Skip answer\n if (metadata?.commands?.skipAnswer) {\n this.log('Skipping answer')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n // Send answer to client\n this.log('Assistant message:', message)\n this.addMessage({ role: 'assistant', content, metadata })\n\n // TTS: Generate answer audio\n if (!this.config.disableTTS) {\n try {\n // Remove last assistant message if aborted\n const onAbort = () => {\n this.log('Answer aborted, removing last assistant message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'assistant') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastAssistantMessage)\n }\n }\n\n if (processing.aborted) {\n onAbort()\n return\n }\n\n const audio = await this.config.text2Speech(content)\n\n // Send audio to client\n await this.sendAudio(audio, processing, onAbort)\n } catch (error) {\n console.error('[CallSocket]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // End of call\n if (metadata?.commands?.endCall) {\n this.log('Call ended')\n this.socket.send(CallServerCommands.EndCall)\n }\n }\n\n private log(...message: any[]) {\n if (!this.config?.debugLog) return\n const now = Date.now()\n const delta = now - this.lastDebug\n this.lastDebug = now\n console.log(`[Debug +${delta}ms]`, ...message)\n }\n}\n","export enum CallClientCommands {\n StartSpeaking = 'StartSpeaking',\n StopSpeaking = 'StopSpeaking',\n Mute = 'Mute',\n}\n\nexport enum CallServerCommands {\n Message = 'Message',\n CancelLastAssistantMessage = 'CancelLastAssistantMessage',\n CancelLastUserMessage = 'CancelLastUserMessage',\n SkipAnswer = 'SkipAnswer',\n EnableSpeakerStreaming = 'EnableSpeakerStreaming',\n EndCall = 'EndCall',\n}\n\nexport interface CallConfig {\n systemPrompt: string\n firstMessage?: string\n debugLog?: boolean\n debugSaveSpeech?: boolean\n disableTTS?: boolean\n generateAnswer(\n conversation: Conversation\n ): Promise<string | ConversationMessage>\n speech2Text(audioBlob: Blob, prevMessage?: string): Promise<string>\n text2Speech(text: string): Promise<ArrayBuffer | NodeJS.ReadableStream>\n onMessage?(message: ConversationMessage): void\n onEnd?(call: CallSummary): void\n}\n\nexport interface CallSummary {\n conversation: Conversation\n duration: number\n}\n\nexport type Conversation = ConversationMessage[]\n\nexport type CallMetadata = {\n commands?: {\n endCall?: boolean\n cancelLastUserMessage?: boolean\n skipAnswer?: boolean\n }\n [key: string]: any\n}\n\nexport interface ConversationMessage<Data extends CallMetadata = CallMetadata> {\n role: 'system' | 'user' | 'assistant'\n content: string\n metadata?: Data\n}\n","import WebSocket from 'ws'\n\nexport enum CallErrorCode {\n BadRequest = 4400,\n Unauthorized = 4401,\n NotFound = 4404,\n}\n\nexport class CallError extends Error {\n code: number\n\n constructor(code: number, message: string) {\n super(message)\n this.code = code\n }\n}\n\nexport function handleError(socket: WebSocket, error: unknown) {\n if (error instanceof CallError) {\n socket.close(error.code, error.message)\n } else {\n console.error(error)\n socket.close(1011)\n }\n socket.terminate()\n}\n","import { WebSocket } from 'ws'\nimport { CallError, CallErrorCode } from './errors'\n\nexport async function waitForParams<CallParams>(\n socket: WebSocket,\n validate: (params: any) => CallParams\n): Promise<CallParams> {\n return new Promise<CallParams>((resolve, reject) => {\n // Handle timeout\n const timeout = setTimeout(() => {\n reject(new CallError(CallErrorCode.BadRequest, 'Missing params'))\n }, 3000)\n\n const onParams = (payload: string) => {\n // Clear timeout and listener\n clearTimeout(timeout)\n socket.off('message', onParams)\n\n try {\n // Parse JSON payload\n const params = validate(JSON.parse(payload))\n resolve(params)\n } catch (error) {\n reject(new CallError(CallErrorCode.BadRequest, 'Invalid params'))\n }\n }\n\n // Listen for params\n socket.on('message', onParams)\n })\n}\n"],"mappings":"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","names":["CallClientCommands","CallServerCommands","CallErrorCode"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/CallServer.ts","../src/types.ts","../src/errors.ts","../src/waitForParams.ts"],"sourcesContent":["export * from './CallServer'\nexport * from './errors'\nexport * from './types'\nexport * from './waitForParams'\n","import * as fs from 'fs'\nimport { WebSocket } from 'ws'\nimport {\n CallClientCommands,\n CallConfig,\n CallServerCommands,\n Conversation,\n ConversationMessage,\n} from './types'\n\ninterface Processing {\n aborted: boolean\n}\n\nexport class CallServer {\n public socket: WebSocket | null = null\n public config: CallConfig | null = null\n\n private startTime = Date.now()\n private lastDebug = Date.now()\n\n // An answer can be aborted if user is speaking\n private processing?: Processing\n\n // When user is speaking, we're waiting to chunks or to stop\n private isSpeaking = false\n\n // Chunks of user speech since user started speaking\n private chunks: Buffer[] = []\n\n // Conversation history\n private conversation: Conversation\n\n // Enable speaker streaming\n private speakerStreamingEnabled = false\n\n constructor(socket: WebSocket, config: CallConfig) {\n this.socket = socket\n this.config = config\n this.conversation = [{ role: 'system', content: config.systemPrompt }]\n this.log(`Call started`)\n\n // Assistant speaks first\n if (config.firstMessage) {\n this.answer(config.firstMessage)\n } else {\n this.config\n .generateAnswer(this.conversation)\n .then((answer) => this.answer(answer))\n .catch((error) => {\n console.error('[CallServer]', error)\n socket?.close()\n // TODO: Implement retry\n })\n }\n\n // Listen to events\n socket.on('close', this.onClose.bind(this))\n socket.on('message', this.onMessage.bind(this))\n }\n\n // Reset conversation\n public resetConversation(conversation: Conversation) {\n this.log('Reset conversation')\n this.conversation = conversation\n }\n\n private abortProcessing() {\n if (!this.processing) return\n this.processing.aborted = true\n this.processing = undefined\n }\n\n private addMessage(message: ConversationMessage) {\n if (!this.socket || !this.config) return\n this.conversation.push(message)\n this.socket.send(`${CallServerCommands.Message} ${JSON.stringify(message)}`)\n this.config.onMessage?.(message)\n }\n\n private async sendAudio(\n audio: ArrayBuffer | NodeJS.ReadableStream,\n processing: Processing,\n onAbort?: () => void\n ) {\n if (!this.socket) return\n if (processing.aborted) {\n onAbort?.()\n return\n }\n\n if (Buffer.isBuffer(audio) || audio instanceof ArrayBuffer) {\n // Whole audio as a single buffer\n this.log(`Send audio: (${audio.byteLength} bytes)`)\n this.socket.send(audio)\n } else if ('paused' in audio) {\n // Enable speaker streaming if not already enabled\n if (!this.speakerStreamingEnabled) {\n this.socket.send(CallServerCommands.EnableSpeakerStreaming)\n this.speakerStreamingEnabled = true\n }\n\n // Audio as a stream\n for await (const chunk of audio) {\n if (processing.aborted) {\n onAbort?.()\n return\n }\n this.log(`Send audio chunk (${chunk.length} bytes)`)\n this.socket.send(chunk)\n }\n } else {\n this.log(`Unknown audio type: ${audio}`)\n }\n }\n\n private onClose() {\n if (!this.config) return\n this.log('Connection closed')\n this.abortProcessing()\n const duration = Math.round((Date.now() - this.startTime) / 1000)\n\n // End call callback\n this.config.onEnd?.({\n conversation: this.conversation.slice(1), // Remove system message\n duration,\n })\n\n // Unset params\n this.socket = null\n this.config = null\n }\n\n private async onMessage(message: Buffer) {\n if (!Buffer.isBuffer(message)) {\n console.warn(`[CallServer] Message is not a buffer`)\n return\n }\n\n // Commands\n if (message.byteLength < 15) {\n const cmd = message.toString()\n this.log(`Command: ${cmd}`)\n\n if (cmd === CallClientCommands.StartSpeaking) {\n // User started speaking\n this.isSpeaking = true\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.Mute) {\n // User muted the call\n this.isSpeaking = false\n this.chunks.length = 0\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.StopSpeaking) {\n // User stopped speaking\n this.isSpeaking = false\n await this.onStopSpeaking()\n }\n }\n\n // Audio chunk\n else if (Buffer.isBuffer(message) && this.isSpeaking) {\n this.log(`Received chunk (${message.byteLength} bytes)`)\n this.chunks.push(message)\n }\n }\n\n private async onStopSpeaking() {\n if (!this.config) return\n\n // Do nothing if there is no chunk\n if (this.chunks.length === 0) return\n\n this.abortProcessing()\n const processing = (this.processing = { aborted: false })\n\n // Combine audio blob\n const blob = new Blob(this.chunks, { type: 'audio/ogg' })\n\n // Reset chunks for next user speech\n this.chunks.length = 0\n\n try {\n // Save file to disk\n if (this.config.debugSaveSpeech) {\n const filename = `speech-${Date.now()}.ogg`\n fs.writeFileSync(filename, Buffer.from(await blob.arrayBuffer()))\n this.log(`Saved speech: ${filename}`)\n }\n\n // STT: Get transcript and send to client\n const transcript = await this.config.speech2Text(\n blob,\n this.conversation[this.conversation.length - 1]?.content\n )\n if (!transcript) {\n this.log('Ignoring empty transcript')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n this.log('User transcript:', transcript)\n\n // Send transcript to client\n this.addMessage({ role: 'user', content: transcript })\n\n if (processing.aborted) {\n this.log('Answer aborted, no answer generated')\n return\n }\n\n // LLM: Generate answer\n const answer = await this.config.generateAnswer(this.conversation)\n if (processing.aborted) {\n this.log('Answer aborted, ignoring answer')\n return\n }\n\n await this.answer(answer, processing)\n } catch (error) {\n console.error('[CallServer]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // Add assistant message and send to client with audio (TTS)\n public async answer(\n message: string | ConversationMessage,\n processing?: Processing\n ) {\n if (!this.socket || !this.config) return\n\n if (!processing) {\n this.abortProcessing()\n processing = this.processing = { aborted: false }\n }\n\n if (typeof message === 'string') {\n message = { role: 'assistant', content: message }\n }\n\n // Cancel last user message\n if (message.commands?.cancelLastUserMessage) {\n this.log('Cancelling last user message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'user') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastUserMessage)\n }\n return\n }\n\n // Skip answer\n if (!message.content.length || message.commands?.skipAnswer) {\n this.log('Skipping answer')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n // Send answer to client\n this.log('Assistant message:', message)\n this.addMessage(message)\n\n // TTS: Generate answer audio\n if (!this.config.disableTTS) {\n try {\n // Remove last assistant message if aborted\n const onAbort = () => {\n this.log('Answer aborted, removing last assistant message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'assistant') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastAssistantMessage)\n }\n }\n\n if (processing.aborted) {\n onAbort()\n return\n }\n\n const audio = await this.config.text2Speech(message.content)\n\n // Send audio to client\n await this.sendAudio(audio, processing, onAbort)\n } catch (error) {\n console.error('[CallServer]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // End of call\n if (message.commands?.endCall) {\n this.log('Call ended')\n this.socket.send(CallServerCommands.EndCall)\n }\n }\n\n private log(...message: any[]) {\n if (!this.config?.debugLog) return\n const now = Date.now()\n const delta = now - this.lastDebug\n this.lastDebug = now\n console.log(`[Debug +${delta}ms]`, ...message)\n }\n}\n","export enum CallClientCommands {\n StartSpeaking = 'StartSpeaking',\n StopSpeaking = 'StopSpeaking',\n Mute = 'Mute',\n}\n\nexport enum CallServerCommands {\n Message = 'Message',\n CancelLastAssistantMessage = 'CancelLastAssistantMessage',\n CancelLastUserMessage = 'CancelLastUserMessage',\n SkipAnswer = 'SkipAnswer',\n EnableSpeakerStreaming = 'EnableSpeakerStreaming',\n EndCall = 'EndCall',\n}\n\nexport interface CallConfig {\n systemPrompt: string\n firstMessage?: string\n debugLog?: boolean\n debugSaveSpeech?: boolean\n disableTTS?: boolean\n generateAnswer(\n conversation: Conversation\n ): Promise<string | ConversationMessage>\n speech2Text(audioBlob: Blob, prevMessage?: string): Promise<string>\n text2Speech(text: string): Promise<ArrayBuffer | NodeJS.ReadableStream>\n onMessage?(message: ConversationMessage): void\n onEnd?(call: CallSummary): void\n}\n\nexport interface CallSummary {\n conversation: Conversation\n duration: number\n}\n\nexport type Conversation = ConversationMessage[]\n\nexport type AnswerCommands = {\n endCall?: boolean\n cancelLastUserMessage?: boolean\n skipAnswer?: boolean\n}\n\nexport type AnswerMetadata = {\n [key: string]: any\n}\n\nexport interface ConversationMessage<\n Data extends AnswerMetadata = AnswerMetadata,\n> {\n role: 'system' | 'user' | 'assistant'\n content: string\n commands?: AnswerCommands\n metadata?: Data\n}\n","import WebSocket from 'ws'\n\nexport enum CallErrorCode {\n BadRequest = 4400,\n Unauthorized = 4401,\n NotFound = 4404,\n}\n\nexport class CallError extends Error {\n code: number\n\n constructor(code: number, message: string) {\n super(message)\n this.code = code\n }\n}\n\nexport function handleError(socket: WebSocket, error: unknown) {\n if (error instanceof CallError) {\n socket.close(error.code, error.message)\n } else {\n console.error(error)\n socket.close(1011)\n }\n socket.terminate()\n}\n","import { WebSocket } from 'ws'\nimport { CallError, CallErrorCode } from './errors'\n\nexport async function waitForParams<CallParams>(\n socket: WebSocket,\n validate: (params: any) => CallParams\n): Promise<CallParams> {\n return new Promise<CallParams>((resolve, reject) => {\n // Handle timeout\n const timeout = setTimeout(() => {\n reject(new CallError(CallErrorCode.BadRequest, 'Missing params'))\n }, 3000)\n\n const onParams = (payload: string) => {\n // Clear timeout and listener\n clearTimeout(timeout)\n socket.off('message', onParams)\n\n try {\n // Parse JSON payload\n const params = validate(JSON.parse(payload))\n resolve(params)\n } catch (error) {\n reject(new CallError(CallErrorCode.BadRequest, 'Invalid params'))\n }\n }\n\n // Listen for params\n socket.on('message', onParams)\n })\n}\n"],"mappings":"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","names":["CallClientCommands","CallServerCommands","CallErrorCode"]}
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // src/CallSocket.ts
1
+ // src/CallServer.ts
2
2
  import * as fs from "fs";
3
3
 
4
4
  // src/types.ts
@@ -18,8 +18,8 @@ var CallServerCommands = /* @__PURE__ */ ((CallServerCommands2) => {
18
18
  return CallServerCommands2;
19
19
  })(CallServerCommands || {});
20
20
 
21
- // src/CallSocket.ts
22
- var CallSocket = class {
21
+ // src/CallServer.ts
22
+ var CallServer = class {
23
23
  constructor(socket, config) {
24
24
  this.socket = null;
25
25
  this.config = null;
@@ -39,7 +39,7 @@ var CallSocket = class {
39
39
  this.answer(config.firstMessage);
40
40
  } else {
41
41
  this.config.generateAnswer(this.conversation).then((answer) => this.answer(answer)).catch((error) => {
42
- console.error("[CallSocket]", error);
42
+ console.error("[CallServer]", error);
43
43
  socket?.close();
44
44
  });
45
45
  }
@@ -103,7 +103,7 @@ var CallSocket = class {
103
103
  }
104
104
  async onMessage(message) {
105
105
  if (!Buffer.isBuffer(message)) {
106
- console.warn(`[CallSocket] Message is not a buffer`);
106
+ console.warn(`[CallServer] Message is not a buffer`);
107
107
  return;
108
108
  }
109
109
  if (message.byteLength < 15) {
@@ -113,6 +113,8 @@ var CallSocket = class {
113
113
  this.isSpeaking = true;
114
114
  this.abortProcessing();
115
115
  } else if (cmd === "Mute" /* Mute */) {
116
+ this.isSpeaking = false;
117
+ this.chunks.length = 0;
116
118
  this.abortProcessing();
117
119
  } else if (cmd === "StopSpeaking" /* StopSpeaking */) {
118
120
  this.isSpeaking = false;
@@ -158,7 +160,7 @@ var CallSocket = class {
158
160
  }
159
161
  await this.answer(answer, processing);
160
162
  } catch (error) {
161
- console.error("[CallSocket]", error);
163
+ console.error("[CallServer]", error);
162
164
  this.socket?.send("SkipAnswer" /* SkipAnswer */);
163
165
  }
164
166
  }
@@ -169,9 +171,10 @@ var CallSocket = class {
169
171
  this.abortProcessing();
170
172
  processing = this.processing = { aborted: false };
171
173
  }
172
- let content = typeof message === "string" ? message : message.content;
173
- const metadata = typeof message === "string" ? void 0 : message.metadata;
174
- if (!content.length || metadata?.commands?.cancelLastUserMessage) {
174
+ if (typeof message === "string") {
175
+ message = { role: "assistant", content: message };
176
+ }
177
+ if (message.commands?.cancelLastUserMessage) {
175
178
  this.log("Cancelling last user message");
176
179
  const lastMessage = this.conversation[this.conversation.length - 1];
177
180
  if (lastMessage?.role === "user") {
@@ -180,13 +183,13 @@ var CallSocket = class {
180
183
  }
181
184
  return;
182
185
  }
183
- if (metadata?.commands?.skipAnswer) {
186
+ if (!message.content.length || message.commands?.skipAnswer) {
184
187
  this.log("Skipping answer");
185
188
  this.socket?.send("SkipAnswer" /* SkipAnswer */);
186
189
  return;
187
190
  }
188
191
  this.log("Assistant message:", message);
189
- this.addMessage({ role: "assistant", content, metadata });
192
+ this.addMessage(message);
190
193
  if (!this.config.disableTTS) {
191
194
  try {
192
195
  const onAbort = () => {
@@ -201,14 +204,14 @@ var CallSocket = class {
201
204
  onAbort();
202
205
  return;
203
206
  }
204
- const audio = await this.config.text2Speech(content);
207
+ const audio = await this.config.text2Speech(message.content);
205
208
  await this.sendAudio(audio, processing, onAbort);
206
209
  } catch (error) {
207
- console.error("[CallSocket]", error);
210
+ console.error("[CallServer]", error);
208
211
  this.socket?.send("SkipAnswer" /* SkipAnswer */);
209
212
  }
210
213
  }
211
- if (metadata?.commands?.endCall) {
214
+ if (message.commands?.endCall) {
212
215
  this.log("Call ended");
213
216
  this.socket.send("EndCall" /* EndCall */);
214
217
  }
@@ -268,8 +271,8 @@ export {
268
271
  CallClientCommands,
269
272
  CallError,
270
273
  CallErrorCode,
274
+ CallServer,
271
275
  CallServerCommands,
272
- CallSocket,
273
276
  handleError,
274
277
  waitForParams
275
278
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/CallSocket.ts","../src/types.ts","../src/errors.ts","../src/waitForParams.ts"],"sourcesContent":["import * as fs from 'fs'\nimport { WebSocket } from 'ws'\nimport {\n CallClientCommands,\n CallConfig,\n CallServerCommands,\n Conversation,\n ConversationMessage,\n} from './types'\n\ninterface Processing {\n aborted: boolean\n}\n\nexport class CallSocket {\n public socket: WebSocket | null = null\n public config: CallConfig | null = null\n\n private startTime = Date.now()\n private lastDebug = Date.now()\n\n // An answer can be aborted if user is speaking\n private processing?: Processing\n\n // When user is speaking, we're waiting to chunks or to stop\n private isSpeaking = false\n\n // Chunks of user speech since user started speaking\n private chunks: Buffer[] = []\n\n // Conversation history\n private conversation: Conversation\n\n // Enable speaker streaming\n private speakerStreamingEnabled = false\n\n constructor(socket: WebSocket, config: CallConfig) {\n this.socket = socket\n this.config = config\n this.conversation = [{ role: 'system', content: config.systemPrompt }]\n this.log(`Call started`)\n\n // Assistant speaks first\n if (config.firstMessage) {\n this.answer(config.firstMessage)\n } else {\n this.config\n .generateAnswer(this.conversation)\n .then((answer) => this.answer(answer))\n .catch((error) => {\n console.error('[CallSocket]', error)\n socket?.close()\n // TODO: Implement retry\n })\n }\n\n // Listen to events\n socket.on('close', this.onClose.bind(this))\n socket.on('message', this.onMessage.bind(this))\n }\n\n // Reset conversation\n public resetConversation(conversation: Conversation) {\n this.log('Reset conversation')\n this.conversation = conversation\n }\n\n private abortProcessing() {\n if (!this.processing) return\n this.processing.aborted = true\n this.processing = undefined\n }\n\n private addMessage(message: ConversationMessage) {\n if (!this.socket || !this.config) return\n this.conversation.push(message)\n this.socket.send(`${CallServerCommands.Message} ${JSON.stringify(message)}`)\n this.config.onMessage?.(message)\n }\n\n private async sendAudio(\n audio: ArrayBuffer | NodeJS.ReadableStream,\n processing: Processing,\n onAbort?: () => void\n ) {\n if (!this.socket) return\n if (processing.aborted) {\n onAbort?.()\n return\n }\n\n if (Buffer.isBuffer(audio) || audio instanceof ArrayBuffer) {\n // Whole audio as a single buffer\n this.log(`Send audio: (${audio.byteLength} bytes)`)\n this.socket.send(audio)\n } else if ('paused' in audio) {\n // Enable speaker streaming if not already enabled\n if (!this.speakerStreamingEnabled) {\n this.socket.send(CallServerCommands.EnableSpeakerStreaming)\n this.speakerStreamingEnabled = true\n }\n\n // Audio as a stream\n for await (const chunk of audio) {\n if (processing.aborted) {\n onAbort?.()\n return\n }\n this.log(`Send audio chunk (${chunk.length} bytes)`)\n this.socket.send(chunk)\n }\n } else {\n this.log(`Unknown audio type: ${audio}`)\n }\n }\n\n private onClose() {\n if (!this.config) return\n this.log('Connection closed')\n this.abortProcessing()\n const duration = Math.round((Date.now() - this.startTime) / 1000)\n\n // End call callback\n this.config.onEnd?.({\n conversation: this.conversation.slice(1), // Remove system message\n duration,\n })\n\n // Unset params\n this.socket = null\n this.config = null\n }\n\n private async onMessage(message: Buffer) {\n if (!Buffer.isBuffer(message)) {\n console.warn(`[CallSocket] Message is not a buffer`)\n return\n }\n\n // Commands\n if (message.byteLength < 15) {\n const cmd = message.toString()\n this.log(`Command: ${cmd}`)\n\n if (cmd === CallClientCommands.StartSpeaking) {\n // User started speaking\n this.isSpeaking = true\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.Mute) {\n // User muted the call\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.StopSpeaking) {\n // User stopped speaking\n this.isSpeaking = false\n await this.onStopSpeaking()\n }\n }\n\n // Audio chunk\n else if (Buffer.isBuffer(message) && this.isSpeaking) {\n this.log(`Received chunk (${message.byteLength} bytes)`)\n this.chunks.push(message)\n }\n }\n\n private async onStopSpeaking() {\n if (!this.config) return\n\n // Do nothing if there is no chunk\n if (this.chunks.length === 0) return\n\n this.abortProcessing()\n const processing = (this.processing = { aborted: false })\n\n // Combine audio blob\n const blob = new Blob(this.chunks, { type: 'audio/ogg' })\n\n // Reset chunks for next user speech\n this.chunks.length = 0\n\n try {\n // Save file to disk\n if (this.config.debugSaveSpeech) {\n const filename = `speech-${Date.now()}.ogg`\n fs.writeFileSync(filename, Buffer.from(await blob.arrayBuffer()))\n this.log(`Saved speech: ${filename}`)\n }\n\n // STT: Get transcript and send to client\n const transcript = await this.config.speech2Text(\n blob,\n this.conversation[this.conversation.length - 1]?.content\n )\n if (!transcript) {\n this.log('Ignoring empty transcript')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n this.log('User transcript:', transcript)\n\n // Send transcript to client\n this.addMessage({ role: 'user', content: transcript })\n\n if (processing.aborted) {\n this.log('Answer aborted, no answer generated')\n return\n }\n\n // LLM: Generate answer\n const answer = await this.config.generateAnswer(this.conversation)\n if (processing.aborted) {\n this.log('Answer aborted, ignoring answer')\n return\n }\n\n await this.answer(answer, processing)\n } catch (error) {\n console.error('[CallSocket]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // Add assistant message and send to client with audio (TTS)\n public async answer(\n message: string | ConversationMessage,\n processing?: Processing\n ) {\n if (!this.socket || !this.config) return\n\n if (!processing) {\n this.abortProcessing()\n processing = this.processing = { aborted: false }\n }\n\n let content = typeof message === 'string' ? message : message.content\n const metadata = typeof message === 'string' ? undefined : message.metadata\n\n // Cancel last user message\n if (!content.length || metadata?.commands?.cancelLastUserMessage) {\n this.log('Cancelling last user message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'user') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastUserMessage)\n }\n return\n }\n\n // Skip answer\n if (metadata?.commands?.skipAnswer) {\n this.log('Skipping answer')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n // Send answer to client\n this.log('Assistant message:', message)\n this.addMessage({ role: 'assistant', content, metadata })\n\n // TTS: Generate answer audio\n if (!this.config.disableTTS) {\n try {\n // Remove last assistant message if aborted\n const onAbort = () => {\n this.log('Answer aborted, removing last assistant message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'assistant') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastAssistantMessage)\n }\n }\n\n if (processing.aborted) {\n onAbort()\n return\n }\n\n const audio = await this.config.text2Speech(content)\n\n // Send audio to client\n await this.sendAudio(audio, processing, onAbort)\n } catch (error) {\n console.error('[CallSocket]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // End of call\n if (metadata?.commands?.endCall) {\n this.log('Call ended')\n this.socket.send(CallServerCommands.EndCall)\n }\n }\n\n private log(...message: any[]) {\n if (!this.config?.debugLog) return\n const now = Date.now()\n const delta = now - this.lastDebug\n this.lastDebug = now\n console.log(`[Debug +${delta}ms]`, ...message)\n }\n}\n","export enum CallClientCommands {\n StartSpeaking = 'StartSpeaking',\n StopSpeaking = 'StopSpeaking',\n Mute = 'Mute',\n}\n\nexport enum CallServerCommands {\n Message = 'Message',\n CancelLastAssistantMessage = 'CancelLastAssistantMessage',\n CancelLastUserMessage = 'CancelLastUserMessage',\n SkipAnswer = 'SkipAnswer',\n EnableSpeakerStreaming = 'EnableSpeakerStreaming',\n EndCall = 'EndCall',\n}\n\nexport interface CallConfig {\n systemPrompt: string\n firstMessage?: string\n debugLog?: boolean\n debugSaveSpeech?: boolean\n disableTTS?: boolean\n generateAnswer(\n conversation: Conversation\n ): Promise<string | ConversationMessage>\n speech2Text(audioBlob: Blob, prevMessage?: string): Promise<string>\n text2Speech(text: string): Promise<ArrayBuffer | NodeJS.ReadableStream>\n onMessage?(message: ConversationMessage): void\n onEnd?(call: CallSummary): void\n}\n\nexport interface CallSummary {\n conversation: Conversation\n duration: number\n}\n\nexport type Conversation = ConversationMessage[]\n\nexport type CallMetadata = {\n commands?: {\n endCall?: boolean\n cancelLastUserMessage?: boolean\n skipAnswer?: boolean\n }\n [key: string]: any\n}\n\nexport interface ConversationMessage<Data extends CallMetadata = CallMetadata> {\n role: 'system' | 'user' | 'assistant'\n content: string\n metadata?: Data\n}\n","import WebSocket from 'ws'\n\nexport enum CallErrorCode {\n BadRequest = 4400,\n Unauthorized = 4401,\n NotFound = 4404,\n}\n\nexport class CallError extends Error {\n code: number\n\n constructor(code: number, message: string) {\n super(message)\n this.code = code\n }\n}\n\nexport function handleError(socket: WebSocket, error: unknown) {\n if (error instanceof CallError) {\n socket.close(error.code, error.message)\n } else {\n console.error(error)\n socket.close(1011)\n }\n socket.terminate()\n}\n","import { WebSocket } from 'ws'\nimport { CallError, CallErrorCode } from './errors'\n\nexport async function waitForParams<CallParams>(\n socket: WebSocket,\n validate: (params: any) => CallParams\n): Promise<CallParams> {\n return new Promise<CallParams>((resolve, reject) => {\n // Handle timeout\n const timeout = setTimeout(() => {\n reject(new CallError(CallErrorCode.BadRequest, 'Missing params'))\n }, 3000)\n\n const onParams = (payload: string) => {\n // Clear timeout and listener\n clearTimeout(timeout)\n socket.off('message', onParams)\n\n try {\n // Parse JSON payload\n const params = validate(JSON.parse(payload))\n resolve(params)\n } catch (error) {\n reject(new CallError(CallErrorCode.BadRequest, 'Invalid params'))\n }\n }\n\n // Listen for params\n socket.on('message', onParams)\n })\n}\n"],"mappings":"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","names":["CallClientCommands","CallServerCommands","CallErrorCode"]}
1
+ {"version":3,"sources":["../src/CallServer.ts","../src/types.ts","../src/errors.ts","../src/waitForParams.ts"],"sourcesContent":["import * as fs from 'fs'\nimport { WebSocket } from 'ws'\nimport {\n CallClientCommands,\n CallConfig,\n CallServerCommands,\n Conversation,\n ConversationMessage,\n} from './types'\n\ninterface Processing {\n aborted: boolean\n}\n\nexport class CallServer {\n public socket: WebSocket | null = null\n public config: CallConfig | null = null\n\n private startTime = Date.now()\n private lastDebug = Date.now()\n\n // An answer can be aborted if user is speaking\n private processing?: Processing\n\n // When user is speaking, we're waiting to chunks or to stop\n private isSpeaking = false\n\n // Chunks of user speech since user started speaking\n private chunks: Buffer[] = []\n\n // Conversation history\n private conversation: Conversation\n\n // Enable speaker streaming\n private speakerStreamingEnabled = false\n\n constructor(socket: WebSocket, config: CallConfig) {\n this.socket = socket\n this.config = config\n this.conversation = [{ role: 'system', content: config.systemPrompt }]\n this.log(`Call started`)\n\n // Assistant speaks first\n if (config.firstMessage) {\n this.answer(config.firstMessage)\n } else {\n this.config\n .generateAnswer(this.conversation)\n .then((answer) => this.answer(answer))\n .catch((error) => {\n console.error('[CallServer]', error)\n socket?.close()\n // TODO: Implement retry\n })\n }\n\n // Listen to events\n socket.on('close', this.onClose.bind(this))\n socket.on('message', this.onMessage.bind(this))\n }\n\n // Reset conversation\n public resetConversation(conversation: Conversation) {\n this.log('Reset conversation')\n this.conversation = conversation\n }\n\n private abortProcessing() {\n if (!this.processing) return\n this.processing.aborted = true\n this.processing = undefined\n }\n\n private addMessage(message: ConversationMessage) {\n if (!this.socket || !this.config) return\n this.conversation.push(message)\n this.socket.send(`${CallServerCommands.Message} ${JSON.stringify(message)}`)\n this.config.onMessage?.(message)\n }\n\n private async sendAudio(\n audio: ArrayBuffer | NodeJS.ReadableStream,\n processing: Processing,\n onAbort?: () => void\n ) {\n if (!this.socket) return\n if (processing.aborted) {\n onAbort?.()\n return\n }\n\n if (Buffer.isBuffer(audio) || audio instanceof ArrayBuffer) {\n // Whole audio as a single buffer\n this.log(`Send audio: (${audio.byteLength} bytes)`)\n this.socket.send(audio)\n } else if ('paused' in audio) {\n // Enable speaker streaming if not already enabled\n if (!this.speakerStreamingEnabled) {\n this.socket.send(CallServerCommands.EnableSpeakerStreaming)\n this.speakerStreamingEnabled = true\n }\n\n // Audio as a stream\n for await (const chunk of audio) {\n if (processing.aborted) {\n onAbort?.()\n return\n }\n this.log(`Send audio chunk (${chunk.length} bytes)`)\n this.socket.send(chunk)\n }\n } else {\n this.log(`Unknown audio type: ${audio}`)\n }\n }\n\n private onClose() {\n if (!this.config) return\n this.log('Connection closed')\n this.abortProcessing()\n const duration = Math.round((Date.now() - this.startTime) / 1000)\n\n // End call callback\n this.config.onEnd?.({\n conversation: this.conversation.slice(1), // Remove system message\n duration,\n })\n\n // Unset params\n this.socket = null\n this.config = null\n }\n\n private async onMessage(message: Buffer) {\n if (!Buffer.isBuffer(message)) {\n console.warn(`[CallServer] Message is not a buffer`)\n return\n }\n\n // Commands\n if (message.byteLength < 15) {\n const cmd = message.toString()\n this.log(`Command: ${cmd}`)\n\n if (cmd === CallClientCommands.StartSpeaking) {\n // User started speaking\n this.isSpeaking = true\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.Mute) {\n // User muted the call\n this.isSpeaking = false\n this.chunks.length = 0\n // Abort answer if there is generation in progress\n this.abortProcessing()\n } else if (cmd === CallClientCommands.StopSpeaking) {\n // User stopped speaking\n this.isSpeaking = false\n await this.onStopSpeaking()\n }\n }\n\n // Audio chunk\n else if (Buffer.isBuffer(message) && this.isSpeaking) {\n this.log(`Received chunk (${message.byteLength} bytes)`)\n this.chunks.push(message)\n }\n }\n\n private async onStopSpeaking() {\n if (!this.config) return\n\n // Do nothing if there is no chunk\n if (this.chunks.length === 0) return\n\n this.abortProcessing()\n const processing = (this.processing = { aborted: false })\n\n // Combine audio blob\n const blob = new Blob(this.chunks, { type: 'audio/ogg' })\n\n // Reset chunks for next user speech\n this.chunks.length = 0\n\n try {\n // Save file to disk\n if (this.config.debugSaveSpeech) {\n const filename = `speech-${Date.now()}.ogg`\n fs.writeFileSync(filename, Buffer.from(await blob.arrayBuffer()))\n this.log(`Saved speech: ${filename}`)\n }\n\n // STT: Get transcript and send to client\n const transcript = await this.config.speech2Text(\n blob,\n this.conversation[this.conversation.length - 1]?.content\n )\n if (!transcript) {\n this.log('Ignoring empty transcript')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n this.log('User transcript:', transcript)\n\n // Send transcript to client\n this.addMessage({ role: 'user', content: transcript })\n\n if (processing.aborted) {\n this.log('Answer aborted, no answer generated')\n return\n }\n\n // LLM: Generate answer\n const answer = await this.config.generateAnswer(this.conversation)\n if (processing.aborted) {\n this.log('Answer aborted, ignoring answer')\n return\n }\n\n await this.answer(answer, processing)\n } catch (error) {\n console.error('[CallServer]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // Add assistant message and send to client with audio (TTS)\n public async answer(\n message: string | ConversationMessage,\n processing?: Processing\n ) {\n if (!this.socket || !this.config) return\n\n if (!processing) {\n this.abortProcessing()\n processing = this.processing = { aborted: false }\n }\n\n if (typeof message === 'string') {\n message = { role: 'assistant', content: message }\n }\n\n // Cancel last user message\n if (message.commands?.cancelLastUserMessage) {\n this.log('Cancelling last user message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'user') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastUserMessage)\n }\n return\n }\n\n // Skip answer\n if (!message.content.length || message.commands?.skipAnswer) {\n this.log('Skipping answer')\n this.socket?.send(CallServerCommands.SkipAnswer)\n return\n }\n\n // Send answer to client\n this.log('Assistant message:', message)\n this.addMessage(message)\n\n // TTS: Generate answer audio\n if (!this.config.disableTTS) {\n try {\n // Remove last assistant message if aborted\n const onAbort = () => {\n this.log('Answer aborted, removing last assistant message')\n const lastMessage = this.conversation[this.conversation.length - 1]\n if (lastMessage?.role === 'assistant') {\n this.conversation.pop()\n this.socket?.send(CallServerCommands.CancelLastAssistantMessage)\n }\n }\n\n if (processing.aborted) {\n onAbort()\n return\n }\n\n const audio = await this.config.text2Speech(message.content)\n\n // Send audio to client\n await this.sendAudio(audio, processing, onAbort)\n } catch (error) {\n console.error('[CallServer]', error)\n this.socket?.send(CallServerCommands.SkipAnswer)\n // TODO: Implement retry\n }\n }\n\n // End of call\n if (message.commands?.endCall) {\n this.log('Call ended')\n this.socket.send(CallServerCommands.EndCall)\n }\n }\n\n private log(...message: any[]) {\n if (!this.config?.debugLog) return\n const now = Date.now()\n const delta = now - this.lastDebug\n this.lastDebug = now\n console.log(`[Debug +${delta}ms]`, ...message)\n }\n}\n","export enum CallClientCommands {\n StartSpeaking = 'StartSpeaking',\n StopSpeaking = 'StopSpeaking',\n Mute = 'Mute',\n}\n\nexport enum CallServerCommands {\n Message = 'Message',\n CancelLastAssistantMessage = 'CancelLastAssistantMessage',\n CancelLastUserMessage = 'CancelLastUserMessage',\n SkipAnswer = 'SkipAnswer',\n EnableSpeakerStreaming = 'EnableSpeakerStreaming',\n EndCall = 'EndCall',\n}\n\nexport interface CallConfig {\n systemPrompt: string\n firstMessage?: string\n debugLog?: boolean\n debugSaveSpeech?: boolean\n disableTTS?: boolean\n generateAnswer(\n conversation: Conversation\n ): Promise<string | ConversationMessage>\n speech2Text(audioBlob: Blob, prevMessage?: string): Promise<string>\n text2Speech(text: string): Promise<ArrayBuffer | NodeJS.ReadableStream>\n onMessage?(message: ConversationMessage): void\n onEnd?(call: CallSummary): void\n}\n\nexport interface CallSummary {\n conversation: Conversation\n duration: number\n}\n\nexport type Conversation = ConversationMessage[]\n\nexport type AnswerCommands = {\n endCall?: boolean\n cancelLastUserMessage?: boolean\n skipAnswer?: boolean\n}\n\nexport type AnswerMetadata = {\n [key: string]: any\n}\n\nexport interface ConversationMessage<\n Data extends AnswerMetadata = AnswerMetadata,\n> {\n role: 'system' | 'user' | 'assistant'\n content: string\n commands?: AnswerCommands\n metadata?: Data\n}\n","import WebSocket from 'ws'\n\nexport enum CallErrorCode {\n BadRequest = 4400,\n Unauthorized = 4401,\n NotFound = 4404,\n}\n\nexport class CallError extends Error {\n code: number\n\n constructor(code: number, message: string) {\n super(message)\n this.code = code\n }\n}\n\nexport function handleError(socket: WebSocket, error: unknown) {\n if (error instanceof CallError) {\n socket.close(error.code, error.message)\n } else {\n console.error(error)\n socket.close(1011)\n }\n socket.terminate()\n}\n","import { WebSocket } from 'ws'\nimport { CallError, CallErrorCode } from './errors'\n\nexport async function waitForParams<CallParams>(\n socket: WebSocket,\n validate: (params: any) => CallParams\n): Promise<CallParams> {\n return new Promise<CallParams>((resolve, reject) => {\n // Handle timeout\n const timeout = setTimeout(() => {\n reject(new CallError(CallErrorCode.BadRequest, 'Missing params'))\n }, 3000)\n\n const onParams = (payload: string) => {\n // Clear timeout and listener\n clearTimeout(timeout)\n socket.off('message', onParams)\n\n try {\n // Parse JSON payload\n const params = validate(JSON.parse(payload))\n resolve(params)\n } catch (error) {\n reject(new CallError(CallErrorCode.BadRequest, 'Invalid params'))\n }\n }\n\n // Listen for params\n socket.on('message', onParams)\n })\n}\n"],"mappings":"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","names":["CallClientCommands","CallServerCommands","CallErrorCode"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micdrop/server",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "A lib for Node.js that helps to use the mic and speaker for voice conversation",
5
5
  "author": "Lonestone",
6
6
  "license": "MIT",