@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 +28 -12
- package/dist/index.d.mts +10 -9
- package/dist/index.d.ts +10 -9
- package/dist/index.js +19 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +18 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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 {
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
###
|
|
109
|
+
### CallServer
|
|
110
110
|
|
|
111
111
|
The main class for managing WebSocket connections and audio streaming.
|
|
112
112
|
|
|
113
113
|
```typescript
|
|
114
|
-
class
|
|
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
|
|
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
|
|
243
|
+
const commands: AnswerCommands = {}
|
|
228
244
|
if (text.includes(END_CALL)) {
|
|
229
245
|
text = text.replace(END_CALL, '').trim()
|
|
230
|
-
|
|
246
|
+
commands.endCall = true
|
|
231
247
|
}
|
|
232
248
|
|
|
233
|
-
return { role: 'assistant', content: text,
|
|
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 {
|
|
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
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
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,
|
|
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/
|
|
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/
|
|
64
|
-
var
|
|
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("[
|
|
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(`[
|
|
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("[
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 (
|
|
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(
|
|
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("[
|
|
252
|
+
console.error("[CallServer]", error);
|
|
250
253
|
this.socket?.send("SkipAnswer" /* SkipAnswer */);
|
|
251
254
|
}
|
|
252
255
|
}
|
|
253
|
-
if (
|
|
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,SAAoB;;;ACAb,IAAK,qBAAL,kBAAKA,wBAAL;AACL,EAAAA,oBAAA,mBAAgB;AAChB,EAAAA,oBAAA,kBAAe;AACf,EAAAA,oBAAA,UAAO;AAHG,SAAAA;AAAA,GAAA;AAML,IAAK,qBAAL,kBAAKC,wBAAL;AACL,EAAAA,oBAAA,aAAU;AACV,EAAAA,oBAAA,gCAA6B;AAC7B,EAAAA,oBAAA,2BAAwB;AACxB,EAAAA,oBAAA,gBAAa;AACb,EAAAA,oBAAA,4BAAyB;AACzB,EAAAA,oBAAA,aAAU;AANA,SAAAA;AAAA,GAAA;;;ADQL,IAAM,aAAN,MAAiB;AAAA,EAsBtB,YAAY,QAAmB,QAAoB;AArBnD,SAAO,SAA2B;AAClC,SAAO,SAA4B;AAEnC,SAAQ,YAAY,KAAK,IAAI;AAC7B,SAAQ,YAAY,KAAK,IAAI;AAM7B;AAAA,SAAQ,aAAa;AAGrB;AAAA,SAAQ,SAAmB,CAAC;AAM5B;AAAA,SAAQ,0BAA0B;AAGhC,SAAK,SAAS;AACd,SAAK,SAAS;AACd,SAAK,eAAe,CAAC,EAAE,MAAM,UAAU,SAAS,OAAO,aAAa,CAAC;AACrE,SAAK,IAAI,cAAc;AAGvB,QAAI,OAAO,cAAc;AACvB,WAAK,OAAO,OAAO,YAAY;AAAA,IACjC,OAAO;AACL,WAAK,OACF,eAAe,KAAK,YAAY,EAChC,KAAK,CAAC,WAAW,KAAK,OAAO,MAAM,CAAC,EACpC,MAAM,CAAC,UAAU;AAChB,gBAAQ,MAAM,gBAAgB,KAAK;AACnC,gBAAQ,MAAM;AAAA,MAEhB,CAAC;AAAA,IACL;AAGA,WAAO,GAAG,SAAS,KAAK,QAAQ,KAAK,IAAI,CAAC;AAC1C,WAAO,GAAG,WAAW,KAAK,UAAU,KAAK,IAAI,CAAC;AAAA,EAChD;AAAA;AAAA,EAGO,kBAAkB,cAA4B;AACnD,SAAK,IAAI,oBAAoB;AAC7B,SAAK,eAAe;AAAA,EACtB;AAAA,EAEQ,kBAAkB;AACxB,QAAI,CAAC,KAAK,WAAY;AACtB,SAAK,WAAW,UAAU;AAC1B,SAAK,aAAa;AAAA,EACpB;AAAA,EAEQ,WAAW,SAA8B;AAC/C,QAAI,CAAC,KAAK,UAAU,CAAC,KAAK,OAAQ;AAClC,SAAK,aAAa,KAAK,OAAO;AAC9B,SAAK,OAAO,KAAK,0BAA6B,IAAI,KAAK,UAAU,OAAO,CAAC,EAAE;AAC3E,SAAK,OAAO,YAAY,OAAO;AAAA,EACjC;AAAA,EAEA,MAAc,UACZ,OACA,YACA,SACA;AACA,QAAI,CAAC,KAAK,OAAQ;AAClB,QAAI,WAAW,SAAS;AACtB,gBAAU;AACV;AAAA,IACF;AAEA,QAAI,OAAO,SAAS,KAAK,KAAK,iBAAiB,aAAa;AAE1D,WAAK,IAAI,gBAAgB,MAAM,UAAU,SAAS;AAClD,WAAK,OAAO,KAAK,KAAK;AAAA,IACxB,WAAW,YAAY,OAAO;AAE5B,UAAI,CAAC,KAAK,yBAAyB;AACjC,aAAK,OAAO,0DAA8C;AAC1D,aAAK,0BAA0B;AAAA,MACjC;AAGA,uBAAiB,SAAS,OAAO;AAC/B,YAAI,WAAW,SAAS;AACtB,oBAAU;AACV;AAAA,QACF;AACA,aAAK,IAAI,qBAAqB,MAAM,MAAM,SAAS;AACnD,aAAK,OAAO,KAAK,KAAK;AAAA,MACxB;AAAA,IACF,OAAO;AACL,WAAK,IAAI,uBAAuB,KAAK,EAAE;AAAA,IACzC;AAAA,EACF;AAAA,EAEQ,UAAU;AAChB,QAAI,CAAC,KAAK,OAAQ;AAClB,SAAK,IAAI,mBAAmB;AAC5B,SAAK,gBAAgB;AACrB,UAAM,WAAW,KAAK,OAAO,KAAK,IAAI,IAAI,KAAK,aAAa,GAAI;AAGhE,SAAK,OAAO,QAAQ;AAAA,MAClB,cAAc,KAAK,aAAa,MAAM,CAAC;AAAA;AAAA,MACvC;AAAA,IACF,CAAC;AAGD,SAAK,SAAS;AACd,SAAK,SAAS;AAAA,EAChB;AAAA,EAEA,MAAc,UAAU,SAAiB;AACvC,QAAI,CAAC,OAAO,SAAS,OAAO,GAAG;AAC7B,cAAQ,KAAK,sCAAsC;AACnD;AAAA,IACF;AAGA,QAAI,QAAQ,aAAa,IAAI;AAC3B,YAAM,MAAM,QAAQ,SAAS;AAC7B,WAAK,IAAI,YAAY,GAAG,EAAE;AAE1B,UAAI,6CAA0C;AAE5C,aAAK,aAAa;AAElB,aAAK,gBAAgB;AAAA,MACvB,WAAW,2BAAiC;AAG1C,aAAK,gBAAgB;AAAA,MACvB,WAAW,2CAAyC;AAElD,aAAK,aAAa;AAClB,cAAM,KAAK,eAAe;AAAA,MAC5B;AAAA,IACF,WAGS,OAAO,SAAS,OAAO,KAAK,KAAK,YAAY;AACpD,WAAK,IAAI,mBAAmB,QAAQ,UAAU,SAAS;AACvD,WAAK,OAAO,KAAK,OAAO;AAAA,IAC1B;AAAA,EACF;AAAA,EAEA,MAAc,iBAAiB;AAC7B,QAAI,CAAC,KAAK,OAAQ;AAGlB,QAAI,KAAK,OAAO,WAAW,EAAG;AAE9B,SAAK,gBAAgB;AACrB,UAAM,aAAc,KAAK,aAAa,EAAE,SAAS,MAAM;AAGvD,UAAM,OAAO,IAAI,KAAK,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGxD,SAAK,OAAO,SAAS;AAErB,QAAI;AAEF,UAAI,KAAK,OAAO,iBAAiB;AAC/B,cAAM,WAAW,UAAU,KAAK,IAAI,CAAC;AACrC,QAAG,iBAAc,UAAU,OAAO,KAAK,MAAM,KAAK,YAAY,CAAC,CAAC;AAChE,aAAK,IAAI,iBAAiB,QAAQ,EAAE;AAAA,MACtC;AAGA,YAAM,aAAa,MAAM,KAAK,OAAO;AAAA,QACnC;AAAA,QACA,KAAK,aAAa,KAAK,aAAa,SAAS,CAAC,GAAG;AAAA,MACnD;AACA,UAAI,CAAC,YAAY;AACf,aAAK,IAAI,2BAA2B;AACpC,aAAK,QAAQ,kCAAkC;AAC/C;AAAA,MACF;AAEA,WAAK,IAAI,oBAAoB,UAAU;AAGvC,WAAK,WAAW,EAAE,MAAM,QAAQ,SAAS,WAAW,CAAC;AAErD,UAAI,WAAW,SAAS;AACtB,aAAK,IAAI,qCAAqC;AAC9C;AAAA,MACF;AAGA,YAAM,SAAS,MAAM,KAAK,OAAO,eAAe,KAAK,YAAY;AACjE,UAAI,WAAW,SAAS;AACtB,aAAK,IAAI,iCAAiC;AAC1C;AAAA,MACF;AAEA,YAAM,KAAK,OAAO,QAAQ,UAAU;AAAA,IACtC,SAAS,OAAO;AACd,cAAQ,MAAM,gBAAgB,KAAK;AACnC,WAAK,QAAQ,kCAAkC;AAAA,IAEjD;AAAA,EACF;AAAA;AAAA,EAGA,MAAa,OACX,SACA,YACA;AACA,QAAI,CAAC,KAAK,UAAU,CAAC,KAAK,OAAQ;AAElC,QAAI,CAAC,YAAY;AACf,WAAK,gBAAgB;AACrB,mBAAa,KAAK,aAAa,EAAE,SAAS,MAAM;AAAA,IAClD;AAEA,QAAI,UAAU,OAAO,YAAY,WAAW,UAAU,QAAQ;AAC9D,UAAM,WAAW,OAAO,YAAY,WAAW,SAAY,QAAQ;AAGnE,QAAI,CAAC,QAAQ,UAAU,UAAU,UAAU,uBAAuB;AAChE,WAAK,IAAI,8BAA8B;AACvC,YAAM,cAAc,KAAK,aAAa,KAAK,aAAa,SAAS,CAAC;AAClE,UAAI,aAAa,SAAS,QAAQ;AAChC,aAAK,aAAa,IAAI;AACtB,aAAK,QAAQ,wDAA6C;AAAA,MAC5D;AACA;AAAA,IACF;AAGA,QAAI,UAAU,UAAU,YAAY;AAClC,WAAK,IAAI,iBAAiB;AAC1B,WAAK,QAAQ,kCAAkC;AAC/C;AAAA,IACF;AAGA,SAAK,IAAI,sBAAsB,OAAO;AACtC,SAAK,WAAW,EAAE,MAAM,aAAa,SAAS,SAAS,CAAC;AAGxD,QAAI,CAAC,KAAK,OAAO,YAAY;AAC3B,UAAI;AAEF,cAAM,UAAU,MAAM;AACpB,eAAK,IAAI,iDAAiD;AAC1D,gBAAM,cAAc,KAAK,aAAa,KAAK,aAAa,SAAS,CAAC;AAClE,cAAI,aAAa,SAAS,aAAa;AACrC,iBAAK,aAAa,IAAI;AACtB,iBAAK,QAAQ,kEAAkD;AAAA,UACjE;AAAA,QACF;AAEA,YAAI,WAAW,SAAS;AACtB,kBAAQ;AACR;AAAA,QACF;AAEA,cAAM,QAAQ,MAAM,KAAK,OAAO,YAAY,OAAO;AAGnD,cAAM,KAAK,UAAU,OAAO,YAAY,OAAO;AAAA,MACjD,SAAS,OAAO;AACd,gBAAQ,MAAM,gBAAgB,KAAK;AACnC,aAAK,QAAQ,kCAAkC;AAAA,MAEjD;AAAA,IACF;AAGA,QAAI,UAAU,UAAU,SAAS;AAC/B,WAAK,IAAI,YAAY;AACrB,WAAK,OAAO,4BAA+B;AAAA,IAC7C;AAAA,EACF;AAAA,EAEQ,OAAO,SAAgB;AAC7B,QAAI,CAAC,KAAK,QAAQ,SAAU;AAC5B,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,QAAQ,MAAM,KAAK;AACzB,SAAK,YAAY;AACjB,YAAQ,IAAI,WAAW,KAAK,OAAO,GAAG,OAAO;AAAA,EAC/C;AACF;;;AEhTO,IAAK,gBAAL,kBAAKC,mBAAL;AACL,EAAAA,8BAAA,gBAAa,QAAb;AACA,EAAAA,8BAAA,kBAAe,QAAf;AACA,EAAAA,8BAAA,cAAW,QAAX;AAHU,SAAAA;AAAA,GAAA;AAML,IAAM,YAAN,cAAwB,MAAM;AAAA,EAGnC,YAAY,MAAc,SAAiB;AACzC,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,SAAS,YAAY,QAAmB,OAAgB;AAC7D,MAAI,iBAAiB,WAAW;AAC9B,WAAO,MAAM,MAAM,MAAM,MAAM,OAAO;AAAA,EACxC,OAAO;AACL,YAAQ,MAAM,KAAK;AACnB,WAAO,MAAM,IAAI;AAAA,EACnB;AACA,SAAO,UAAU;AACnB;;;ACtBA,eAAsB,cACpB,QACA,UACqB;AACrB,SAAO,IAAI,QAAoB,CAAC,SAAS,WAAW;AAElD,UAAM,UAAU,WAAW,MAAM;AAC/B,aAAO,IAAI,iCAAoC,gBAAgB,CAAC;AAAA,IAClE,GAAG,GAAI;AAEP,UAAM,WAAW,CAAC,YAAoB;AAEpC,mBAAa,OAAO;AACpB,aAAO,IAAI,WAAW,QAAQ;AAE9B,UAAI;AAEF,cAAM,SAAS,SAAS,KAAK,MAAM,OAAO,CAAC;AAC3C,gBAAQ,MAAM;AAAA,MAChB,SAAS,OAAO;AACd,eAAO,IAAI,iCAAoC,gBAAgB,CAAC;AAAA,MAClE;AAAA,IACF;AAGA,WAAO,GAAG,WAAW,QAAQ;AAAA,EAC/B,CAAC;AACH;","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/
|
|
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/
|
|
22
|
-
var
|
|
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("[
|
|
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(`[
|
|
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("[
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 (
|
|
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(
|
|
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("[
|
|
210
|
+
console.error("[CallServer]", error);
|
|
208
211
|
this.socket?.send("SkipAnswer" /* SkipAnswer */);
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
|
-
if (
|
|
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
|
};
|
package/dist/index.mjs.map
CHANGED
|
@@ -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"]}
|