voicecc 1.1.36 → 1.2.0

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.
Files changed (45) hide show
  1. package/bin/voicecc.js +94 -1
  2. package/dashboard/dist/assets/index-DCeOdulF.js +28 -0
  3. package/dashboard/dist/index.html +1 -1
  4. package/dashboard/routes/agents.ts +28 -8
  5. package/dashboard/routes/browser-call.ts +3 -2
  6. package/dashboard/routes/chat.ts +75 -55
  7. package/dashboard/routes/providers.ts +5 -74
  8. package/dashboard/routes/twilio.ts +104 -5
  9. package/dashboard/routes/voice.ts +98 -0
  10. package/dashboard/server.ts +48 -1
  11. package/package.json +2 -3
  12. package/server/index.ts +96 -8
  13. package/server/services/twilio-manager.ts +29 -10
  14. package/dashboard/dist/assets/index-C62C9Gp0.js +0 -28
  15. package/dashboard/dist/audio-processor.js +0 -126
  16. package/server/services/heartbeat.ts +0 -403
  17. package/server/voice/assets/chime.wav +0 -0
  18. package/server/voice/assets/startup.pcm +0 -0
  19. package/server/voice/audio-adapter.ts +0 -60
  20. package/server/voice/audio-inactivity.test.ts +0 -108
  21. package/server/voice/audio-inactivity.ts +0 -91
  22. package/server/voice/browser-audio-playback.test.ts +0 -149
  23. package/server/voice/browser-audio.ts +0 -147
  24. package/server/voice/browser-server.ts +0 -311
  25. package/server/voice/chat-server.ts +0 -236
  26. package/server/voice/chime.test.ts +0 -69
  27. package/server/voice/chime.ts +0 -36
  28. package/server/voice/claude-session.ts +0 -293
  29. package/server/voice/endpointing.ts +0 -163
  30. package/server/voice/mic-vpio +0 -0
  31. package/server/voice/narration.ts +0 -204
  32. package/server/voice/prompt-builder.ts +0 -108
  33. package/server/voice/session-lock.ts +0 -123
  34. package/server/voice/stt-elevenlabs.ts +0 -210
  35. package/server/voice/stt-provider.ts +0 -106
  36. package/server/voice/tts-elevenlabs-hiss.test.ts +0 -183
  37. package/server/voice/tts-elevenlabs.ts +0 -397
  38. package/server/voice/tts-provider.ts +0 -155
  39. package/server/voice/twilio-audio.ts +0 -338
  40. package/server/voice/twilio-server.ts +0 -540
  41. package/server/voice/types.ts +0 -282
  42. package/server/voice/vad.ts +0 -101
  43. package/server/voice/voice-loop-bugs.test.ts +0 -348
  44. package/server/voice/voice-server.ts +0 -129
  45. package/server/voice/voice-session.ts +0 -539
@@ -1,348 +0,0 @@
1
- /**
2
- * Tests that reproduce known voice loop bugs.
3
- *
4
- * Run: npx tsx --test server/voice/voice-loop-bugs.test.ts
5
- */
6
-
7
- import { test } from "node:test";
8
- import { strict as assert } from "node:assert";
9
-
10
- import { createClaudeSession } from "./claude-session.js";
11
- import type { TextChunk, ClaudeSessionConfig, ClaudeStreamEvent } from "./types.js";
12
-
13
- // ============================================================================
14
- // HELPERS -- Mock SDK query function
15
- // ============================================================================
16
-
17
- /** A single SDK event to yield, with optional delay before yielding. */
18
- interface MockStep {
19
- /** The SDK message object to yield */
20
- event: Record<string, unknown>;
21
- /** Delay in ms before yielding this event (default: 0) */
22
- delayMs?: number;
23
- }
24
-
25
- /** Events to yield for a single user turn. */
26
- interface MockTurn {
27
- steps: MockStep[];
28
- }
29
-
30
- function sleep(ms: number): Promise<void> {
31
- return new Promise((r) => setTimeout(r, ms));
32
- }
33
-
34
- /**
35
- * Create a mock query function that replaces the real Claude SDK.
36
- * Consumes user messages from the prompt iterable and yields pre-configured
37
- * SDK events for each turn, with optional delays between events.
38
- * @param turns - Array of turn configurations, consumed in order
39
- * @returns A function matching the SDK query() signature (cast with `as any`)
40
- */
41
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
- function createMockQueryFn(turns: MockTurn[]): any {
43
- return function mockQueryFn({ prompt }: { prompt: AsyncIterable<unknown> }) {
44
- let turnIndex = 0;
45
-
46
- async function* generateEvents(): AsyncGenerator<Record<string, unknown>> {
47
- for await (const _userMsg of prompt) {
48
- const turn = turns[turnIndex++];
49
- if (!turn) return;
50
-
51
- for (const step of turn.steps) {
52
- if (step.delayMs) await sleep(step.delayMs);
53
- yield step.event;
54
- }
55
- }
56
- }
57
-
58
- const gen = generateEvents();
59
-
60
- // Return an async iterable with an interrupt() method (matches SDK Query interface)
61
- return Object.assign(gen, {
62
- interrupt(): void { /* no-op: mock does not support real interruption */ },
63
- });
64
- };
65
- }
66
-
67
- // -- SDK message factory helpers --
68
-
69
- function makeSystemEvent(sessionId: string): Record<string, unknown> {
70
- return { type: "system", session_id: sessionId };
71
- }
72
-
73
- function makeBlockStart(index = 0): Record<string, unknown> {
74
- return {
75
- type: "stream_event",
76
- event: {
77
- type: "content_block_start",
78
- index,
79
- content_block: { type: "text", text: "" },
80
- },
81
- };
82
- }
83
-
84
- function makeTextDelta(text: string, index = 0): Record<string, unknown> {
85
- return {
86
- type: "stream_event",
87
- event: {
88
- type: "content_block_delta",
89
- index,
90
- delta: { type: "text_delta", text },
91
- },
92
- };
93
- }
94
-
95
- function makeBlockStop(index = 0): Record<string, unknown> {
96
- return {
97
- type: "stream_event",
98
- event: { type: "content_block_stop", index },
99
- };
100
- }
101
-
102
- function makeAssistant(text: string): Record<string, unknown> {
103
- return {
104
- type: "assistant",
105
- message: { content: [{ type: "text", text }] },
106
- };
107
- }
108
-
109
- function makeResult(): Record<string, unknown> {
110
- return { type: "result", is_error: false, subtype: "success" };
111
- }
112
-
113
- // ============================================================================
114
- // BUG 2: Stale Claude session events after interrupt
115
- // ============================================================================
116
-
117
- /**
118
- * Verifies that after interrupting a Claude session mid-turn, the next
119
- * sendMessage call only receives events from the new turn -- no stale
120
- * text deltas or assistant messages from the interrupted turn leak through.
121
- */
122
- test("BUG: after interrupt, next sendMessage receives stale events from previous turn", { timeout: 10_000 }, async () => {
123
- const mockQueryFn = createMockQueryFn([
124
- {
125
- steps: [
126
- { event: makeSystemEvent("test-session") },
127
- { event: makeBlockStart(0) },
128
- { event: makeTextDelta("Turn one response", 0) },
129
- { event: makeBlockStop(0) },
130
- { event: makeAssistant("Turn one response"), delayMs: 50 },
131
- { event: makeResult() },
132
- ],
133
- },
134
- {
135
- steps: [
136
- { event: makeBlockStart(0) },
137
- { event: makeTextDelta("Turn two response", 0) },
138
- { event: makeBlockStop(0) },
139
- { event: makeAssistant("Turn two response") },
140
- { event: makeResult() },
141
- ],
142
- },
143
- ]);
144
-
145
- const config: ClaudeSessionConfig = {
146
- allowedTools: [],
147
- permissionMode: "bypassPermissions",
148
- systemPrompt: "test",
149
- };
150
-
151
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock needs type flexibility
152
- const session = await createClaudeSession(config, mockQueryFn as any);
153
-
154
- try {
155
- const turn1Events: ClaudeStreamEvent[] = [];
156
- for await (const event of session.sendMessage("turn 1 question")) {
157
- turn1Events.push(event);
158
- if (event.type === "text_delta") break;
159
- }
160
-
161
- assert.ok(
162
- turn1Events.some((e) => e.type === "text_delta" && e.content.includes("Turn one")),
163
- "Turn 1 should have yielded a text delta"
164
- );
165
-
166
- session.interrupt();
167
- await sleep(10);
168
-
169
- const turn2Events: ClaudeStreamEvent[] = [];
170
- for await (const event of session.sendMessage("turn 2 question")) {
171
- turn2Events.push(event);
172
- }
173
-
174
- const staleDeltas = turn2Events.filter(
175
- (e) => e.type === "text_delta" && e.content.includes("Turn one")
176
- );
177
- assert.equal(
178
- staleDeltas.length, 0,
179
- `Found stale turn-1 text in turn-2 events: ${JSON.stringify(staleDeltas)}. ` +
180
- `Events from the interrupted turn leaked through the event queue into the next turn.`
181
- );
182
-
183
- const turn2Deltas = turn2Events.filter(
184
- (e) => e.type === "text_delta" && e.content.includes("Turn two")
185
- );
186
- assert.ok(
187
- turn2Deltas.length > 0,
188
- `Turn 2 should have yielded text deltas with 'Turn two' content, ` +
189
- `but got: ${JSON.stringify(turn2Events.filter((e) => e.type === "text_delta"))}`
190
- );
191
- } finally {
192
- await session.close();
193
- }
194
- });
195
-
196
- // ============================================================================
197
- // BUG 5: Narration summaries queue up and burst at tool_end
198
- // ============================================================================
199
-
200
- /**
201
- * Verifies that periodic summaries from long-running tools are emitted
202
- * immediately as they're generated (via the timer), not queued up and
203
- * drained all at once when the tool ends.
204
- */
205
- test("BUG: narration summaries should emit immediately, not queue and burst at end", { timeout: 15_000 }, async () => {
206
- const { createNarrator } = await import("./narration.js");
207
-
208
- const emittedTexts: Array<{ text: string; timestamp: number }> = [];
209
- const startTime = Date.now();
210
-
211
- const narrator = createNarrator({
212
- summaryIntervalMs: 100,
213
- }, (text: string) => {
214
- emittedTexts.push({ text, timestamp: Date.now() - startTime });
215
- });
216
-
217
- const toolStart: ClaudeStreamEvent = { type: "tool_start", toolName: "Write", content: "" };
218
- const initialTexts = narrator.processEvent(toolStart);
219
-
220
- for (const text of initialTexts) {
221
- emittedTexts.push({ text, timestamp: Date.now() - startTime });
222
- }
223
-
224
- await new Promise((r) => setTimeout(r, 450));
225
-
226
- const toolEnd: ClaudeStreamEvent = { type: "tool_end", content: "" };
227
- const endTexts = narrator.processEvent(toolEnd);
228
-
229
- for (const text of endTexts) {
230
- emittedTexts.push({ text, timestamp: Date.now() - startTime });
231
- }
232
-
233
- const stillWorkingMessages = emittedTexts.filter(e => e.text.includes("Still working"));
234
-
235
- assert.ok(
236
- stillWorkingMessages.length >= 3,
237
- `Expected at least 3 "Still working" messages over 450ms with 100ms interval, got ${stillWorkingMessages.length}`
238
- );
239
-
240
- const timestamps = stillWorkingMessages.map(e => e.timestamp);
241
- const allAtEnd = timestamps.every(t => t > 400);
242
-
243
- assert.ok(
244
- !allAtEnd,
245
- `All ${stillWorkingMessages.length} "Still working" messages arrived after 400ms (timestamps: ${timestamps}). ` +
246
- `They were queued in pendingSummaries and drained in a burst at tool_end, ` +
247
- `instead of being emitted immediately as the timer fired.`
248
- );
249
-
250
- narrator.reset();
251
- });
252
-
253
- // ============================================================================
254
- // BUG: "Thinking" is spoken at the end of thinking, not the beginning
255
- // ============================================================================
256
-
257
- /**
258
- * When the model uses extended thinking, the user should hear "Thinking..."
259
- * BEFORE the thinking completes -- not after. The SDK delivers the thinking
260
- * content_block_start event only once thinking tokens start streaming, which
261
- * can be seconds into the turn. The "Thinking..." announcement should arrive
262
- * early in the turn, before the bulk of thinking finishes.
263
- *
264
- * This test simulates a thinking block that takes 2 seconds, followed by text.
265
- * "Thinking..." must appear within the first second -- not after the 2s delay.
266
- *
267
- * STATUS: No fix yet. The SDK provides no early signal that thinking has started.
268
- * The content_block_start type="thinking" event only arrives after ~1.7s+ of API
269
- * latency (on top of any actual thinking time). See timing investigation in:
270
- * testscripts/2026.03.10-thinking-timing/test-sdk-event-timing.mjs
271
- * testscripts/2026.03.10-thinking-timing/timing.log
272
- */
273
- test.skip("BUG: 'Thinking' should be announced early, not after thinking completes", { timeout: 10_000 }, async () => {
274
- // Simulate realistic SDK behaviour: the thinking content_block_start arrives
275
- // late (after ~2s of thinking), then text follows shortly after.
276
- function makeThinkingBlockStart(index = 0): Record<string, unknown> {
277
- return {
278
- type: "stream_event",
279
- event: {
280
- type: "content_block_start",
281
- index,
282
- content_block: { type: "thinking", thinking: "" },
283
- },
284
- };
285
- }
286
-
287
- function makeThinkingBlockStop(index = 0): Record<string, unknown> {
288
- return {
289
- type: "stream_event",
290
- event: { type: "content_block_stop", index },
291
- };
292
- }
293
-
294
- const mockQueryFn = createMockQueryFn([
295
- {
296
- steps: [
297
- { event: makeSystemEvent("test-session") },
298
- // Thinking block arrives after 2s (simulates SDK buffering)
299
- { event: makeThinkingBlockStart(0), delayMs: 2000 },
300
- { event: makeThinkingBlockStop(0) },
301
- // Then the actual text response
302
- { event: makeBlockStart(1) },
303
- { event: makeTextDelta("Here is my answer.", 1) },
304
- { event: makeBlockStop(1) },
305
- { event: makeAssistant("Here is my answer.") },
306
- { event: makeResult() },
307
- ],
308
- },
309
- ]);
310
-
311
- const config: ClaudeSessionConfig = {
312
- allowedTools: [],
313
- permissionMode: "bypassPermissions",
314
- systemPrompt: "test",
315
- };
316
-
317
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
318
- const session = await createClaudeSession(config, mockQueryFn as any);
319
-
320
- try {
321
- const events: Array<{ event: ClaudeStreamEvent; elapsedMs: number }> = [];
322
- const t0 = Date.now();
323
-
324
- for await (const event of session.sendMessage("complex question")) {
325
- events.push({ event, elapsedMs: Date.now() - t0 });
326
- }
327
-
328
- // Find the "Thinking" announcement
329
- const thinkingEvent = events.find(
330
- (e) => e.event.type === "text_delta" && e.event.content.toLowerCase().includes("thinking")
331
- );
332
-
333
- assert.ok(
334
- thinkingEvent,
335
- `Expected a "Thinking..." text delta, but none was found. Events: ${JSON.stringify(events.map(e => e.event))}`
336
- );
337
-
338
- // The "Thinking..." must arrive within the first second, NOT after the 2s delay
339
- assert.ok(
340
- thinkingEvent!.elapsedMs < 1000,
341
- `"Thinking..." was announced at ${thinkingEvent!.elapsedMs}ms, but should arrive within 1000ms. ` +
342
- `It's being announced when the SDK delivers the thinking event (after thinking completes), ` +
343
- `not proactively at the start of the turn.`
344
- );
345
- } finally {
346
- await session.close();
347
- }
348
- });
@@ -1,129 +0,0 @@
1
- /**
2
- * Unified HTTP + WebSocket voice server.
3
- *
4
- * Runs on TWILIO_PORT (default 8080) and handles both Twilio and browser
5
- * audio connections on the same port. All other HTTP requests are proxied
6
- * to the dashboard server.
7
- *
8
- * Routes:
9
- * - POST /twilio/incoming-call → Twilio webhook handler
10
- * - POST /register-call → outbound call token registration
11
- * - WS /media/:token → Twilio media stream
12
- * - WS /audio?token=<token> → Browser audio stream
13
- * - * → proxy to dashboard
14
- */
15
-
16
- import { join } from "node:path";
17
- import { homedir } from "node:os";
18
- import { config } from "dotenv";
19
- config({ path: process.env.VOICECC_DIR ? join(process.env.VOICECC_DIR, ".env") : join(homedir(), ".voicecc", ".env") });
20
-
21
- import { createServer, request as httpRequest } from "http";
22
- import { WebSocketServer } from "ws";
23
-
24
- import { handleTwilioHttpRequest, handleTwilioUpgrade } from "./twilio-server.js";
25
- import { handleBrowserUpgrade } from "./browser-server.js";
26
-
27
- import type { IncomingMessage, ServerResponse } from "http";
28
- import type { Duplex } from "stream";
29
-
30
- // ============================================================================
31
- // CONSTANTS
32
- // ============================================================================
33
-
34
- const DEFAULT_PORT = 8080;
35
-
36
- /** Ping interval to keep WebSocket connections alive through tunnel (ms) */
37
- const PING_INTERVAL_MS = 30_000;
38
-
39
- // ============================================================================
40
- // MAIN ENTRYPOINT
41
- // ============================================================================
42
-
43
- /**
44
- * Start the unified voice server.
45
- *
46
- * Creates an HTTP server that routes Twilio webhooks and browser/Twilio
47
- * WebSocket upgrades, proxying everything else to the dashboard.
48
- *
49
- * @param dashboardPort - Dashboard server port for proxying
50
- * @returns Resolves when the server is listening
51
- */
52
- export async function startVoiceServer(dashboardPort: number): Promise<number> {
53
- const port = parseInt(process.env.TWILIO_PORT ?? "", 10) || DEFAULT_PORT;
54
-
55
- const server = createServer((req, res) => {
56
- // Try Twilio HTTP handlers first
57
- if (handleTwilioHttpRequest(req, res)) {
58
- return;
59
- }
60
-
61
- // Proxy everything else to the dashboard
62
- proxyToDashboard(req, res, dashboardPort);
63
- });
64
-
65
- const wss = new WebSocketServer({ noServer: true });
66
-
67
- // Route WebSocket upgrades by path
68
- server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
69
- const url = new URL(req.url ?? "", `http://${req.headers.host}`);
70
-
71
- if (url.pathname.startsWith("/media/")) {
72
- handleTwilioUpgrade(req, socket, head, wss);
73
- } else if (url.pathname === "/audio") {
74
- handleBrowserUpgrade(req, socket, head, wss);
75
- } else {
76
- socket.destroy();
77
- }
78
- });
79
-
80
- // Periodic ping to keep connections alive through tunnel
81
- setInterval(() => {
82
- wss.clients.forEach((ws) => {
83
- if (ws.readyState === ws.OPEN) {
84
- ws.ping();
85
- }
86
- });
87
- }, PING_INTERVAL_MS);
88
-
89
- return new Promise<number>((resolve) => {
90
- server.listen(port, () => {
91
- console.log(`Voice server listening on port ${port}`);
92
- resolve(port);
93
- });
94
- });
95
- }
96
-
97
- // ============================================================================
98
- // HELPER FUNCTIONS
99
- // ============================================================================
100
-
101
- /**
102
- * Proxy an HTTP request to the dashboard server on localhost.
103
- *
104
- * @param req - Original incoming request
105
- * @param res - Response to write the proxied result to
106
- * @param dashboardPort - Port the dashboard server is listening on
107
- */
108
- function proxyToDashboard(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void {
109
- const proxyReq = httpRequest(
110
- {
111
- hostname: "127.0.0.1",
112
- port: dashboardPort,
113
- path: req.url,
114
- method: req.method,
115
- headers: { ...req.headers, "x-forwarded-for": "127.0.0.1" },
116
- },
117
- (proxyRes) => {
118
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
119
- proxyRes.pipe(res);
120
- },
121
- );
122
-
123
- proxyReq.on("error", () => {
124
- res.writeHead(502, { "Content-Type": "text/plain" });
125
- res.end("Dashboard unavailable");
126
- });
127
-
128
- req.pipe(proxyReq);
129
- }