voicecc 1.0.7
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/.claude-plugin/plugin.json +6 -0
- package/README.md +48 -0
- package/bin/voicecc.js +39 -0
- package/dashboard/dist/assets/index-BXemFrMp.css +1 -0
- package/dashboard/dist/assets/index-dAYfRls7.js +11 -0
- package/dashboard/dist/audio-processor.js +126 -0
- package/dashboard/dist/index.html +13 -0
- package/dashboard/routes/auth.ts +119 -0
- package/dashboard/routes/browser-call.ts +87 -0
- package/dashboard/routes/claude-md.ts +50 -0
- package/dashboard/routes/conversations.ts +203 -0
- package/dashboard/routes/integrations.ts +154 -0
- package/dashboard/routes/mcp-servers.ts +198 -0
- package/dashboard/routes/settings.ts +64 -0
- package/dashboard/routes/tunnel.ts +66 -0
- package/dashboard/routes/twilio.ts +120 -0
- package/dashboard/routes/voice.ts +48 -0
- package/dashboard/routes/webrtc.ts +85 -0
- package/dashboard/server.ts +130 -0
- package/dashboard/tsconfig.json +13 -0
- package/init/CLAUDE.md +18 -0
- package/package.json +59 -0
- package/run.ts +68 -0
- package/scripts/postinstall.js +228 -0
- package/services/browser-call-manager.ts +106 -0
- package/services/device-pairing.ts +176 -0
- package/services/env.ts +88 -0
- package/services/tunnel.ts +204 -0
- package/services/twilio-manager.ts +126 -0
- package/sidecar/assets/startup.pcm +0 -0
- package/sidecar/audio-adapter.ts +60 -0
- package/sidecar/audio-capture.ts +220 -0
- package/sidecar/browser-audio-playback.test.ts +149 -0
- package/sidecar/browser-audio.ts +147 -0
- package/sidecar/browser-server.ts +331 -0
- package/sidecar/chime.test.ts +69 -0
- package/sidecar/chime.ts +54 -0
- package/sidecar/claude-session.ts +295 -0
- package/sidecar/endpointing.ts +163 -0
- package/sidecar/index.ts +83 -0
- package/sidecar/local-audio.ts +126 -0
- package/sidecar/mic-vpio +0 -0
- package/sidecar/mic-vpio.swift +484 -0
- package/sidecar/mock-tts-server-tagged.mjs +132 -0
- package/sidecar/narration.ts +204 -0
- package/sidecar/scripts/generate-startup-audio.py +79 -0
- package/sidecar/session-lock.ts +123 -0
- package/sidecar/sherpa-onnx-node.d.ts +4 -0
- package/sidecar/stt.ts +199 -0
- package/sidecar/tts-server.py +193 -0
- package/sidecar/tts.ts +481 -0
- package/sidecar/twilio-audio.ts +338 -0
- package/sidecar/twilio-server.ts +436 -0
- package/sidecar/types.ts +210 -0
- package/sidecar/vad.ts +101 -0
- package/sidecar/voice-loop-bugs.test.ts +522 -0
- package/sidecar/voice-session.ts +523 -0
- package/skills/voice/SKILL.md +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioAdapter interface for abstracting audio I/O in voice sessions.
|
|
3
|
+
*
|
|
4
|
+
* Any audio transport (local mic, Twilio, WhatsApp) implements this interface
|
|
5
|
+
* so the voice session logic remains transport-agnostic.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Define a common contract for audio input (microphone) and output (speaker)
|
|
9
|
+
* - Support playback interruption and resumption
|
|
10
|
+
* - Provide a ready chime signal
|
|
11
|
+
* - Clean up resources on destroy
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// INTERFACES
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Abstraction over audio I/O for the voice session.
|
|
20
|
+
* Implemented by local-audio.ts (VPIO) and twilio-audio.ts (WebSocket).
|
|
21
|
+
*/
|
|
22
|
+
export interface AudioAdapter {
|
|
23
|
+
/**
|
|
24
|
+
* Subscribe to incoming audio chunks from the microphone.
|
|
25
|
+
* The callback receives Float32Array samples (16kHz, normalized -1.0 to 1.0).
|
|
26
|
+
* The callback is synchronous -- the consumer wraps async work internally.
|
|
27
|
+
*
|
|
28
|
+
* @param callback - Called with each audio chunk as Float32Array
|
|
29
|
+
*/
|
|
30
|
+
onAudio: (callback: (samples: Float32Array) => void) => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Write PCM audio to the speaker output.
|
|
34
|
+
* Audio format: 16-bit signed, 24kHz mono.
|
|
35
|
+
*
|
|
36
|
+
* @param pcm - Raw PCM buffer to play
|
|
37
|
+
* @returns Resolves when the write completes (backpressure)
|
|
38
|
+
*/
|
|
39
|
+
writeSpeaker: (pcm: Buffer) => Promise<void>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clear the output audio buffer immediately (user interruption).
|
|
43
|
+
*/
|
|
44
|
+
interrupt: () => void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resume output after an interrupt. Must be called before writing new audio.
|
|
48
|
+
*/
|
|
49
|
+
resume: () => void;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Play the ready chime through the output.
|
|
53
|
+
*/
|
|
54
|
+
playChime: () => void;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clean up all resources (kill processes, close connections).
|
|
58
|
+
*/
|
|
59
|
+
destroy: () => void;
|
|
60
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio I/O via macOS Voice Processing IO (VPIO) with echo cancellation.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a native mic-vpio binary that uses macOS's built-in acoustic echo
|
|
5
|
+
* cancellation. The binary handles both mic capture and speaker playback
|
|
6
|
+
* through a single VPIO AudioUnit, so the AEC has a reference signal of
|
|
7
|
+
* what's being played to subtract from the mic input.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Start/stop the mic-vpio binary for echo-cancelled audio I/O
|
|
11
|
+
* - Provide a readable stream of echo-cancelled 16-bit signed PCM mic data
|
|
12
|
+
* - Provide a writable stream for TTS audio playback
|
|
13
|
+
* - Support playback interruption (clears audio buffer via SIGUSR1)
|
|
14
|
+
* - Convert raw PCM buffers to Float32Array for downstream VAD/STT consumption
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn, type ChildProcess } from "child_process";
|
|
18
|
+
import { join, dirname } from "path";
|
|
19
|
+
import { fileURLToPath } from "url";
|
|
20
|
+
|
|
21
|
+
import type { Readable, Writable } from "stream";
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// CONSTANTS
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/** Divisor for normalizing 16-bit signed PCM to -1.0..1.0 range */
|
|
28
|
+
const PCM_16BIT_MAX = 32768.0;
|
|
29
|
+
|
|
30
|
+
/** Number of bytes per 16-bit sample */
|
|
31
|
+
const BYTES_PER_SAMPLE = 2;
|
|
32
|
+
|
|
33
|
+
/** Path to the compiled mic-vpio binary */
|
|
34
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const MIC_VPIO_BIN = join(__dirname, "mic-vpio");
|
|
36
|
+
|
|
37
|
+
/** Timeout for the VPIO binary to initialize (ms) */
|
|
38
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// INTERFACES
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
/** Streams returned by startCapture for both mic input and speaker output */
|
|
45
|
+
interface AudioIO {
|
|
46
|
+
/** Readable stream of echo-cancelled mic PCM (16-bit signed, mono) */
|
|
47
|
+
micStream: Readable;
|
|
48
|
+
/** Writable stream for TTS PCM playback (16-bit signed, mono) */
|
|
49
|
+
speakerInput: Writable;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// STATE
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
/** The active mic-vpio child process */
|
|
57
|
+
let vpioProcess: ChildProcess | null = null;
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// MAIN HANDLERS
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start the VPIO audio I/O process with echo cancellation.
|
|
65
|
+
*
|
|
66
|
+
* Spawns the mic-vpio binary which sets up a macOS VoiceProcessingIO AudioUnit.
|
|
67
|
+
* Waits for the binary to report READY before returning.
|
|
68
|
+
*
|
|
69
|
+
* @param micRate - Mic output sample rate in Hz (e.g. 16000 for VAD/STT)
|
|
70
|
+
* @param speakerRate - Speaker input sample rate in Hz (e.g. 24000 for TTS)
|
|
71
|
+
* @returns AudioIO with mic and speaker streams
|
|
72
|
+
* @throws Error if already capturing, binary not found, or initialization fails
|
|
73
|
+
*/
|
|
74
|
+
async function startCapture(micRate: number, speakerRate: number): Promise<AudioIO> {
|
|
75
|
+
if (vpioProcess) {
|
|
76
|
+
throw new Error("Capture already in progress. Call stopCapture() first.");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
vpioProcess = spawn(MIC_VPIO_BIN, [String(micRate), String(speakerRate)]);
|
|
80
|
+
|
|
81
|
+
if (!vpioProcess.stdout || !vpioProcess.stdin) {
|
|
82
|
+
throw new Error("Failed to get mic-vpio stdio streams");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Wait for the binary to report READY on stderr
|
|
86
|
+
await waitForReady(vpioProcess);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
micStream: vpioProcess.stdout,
|
|
90
|
+
speakerInput: vpioProcess.stdin,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Interrupt current speaker playback by clearing the VPIO ring buffer.
|
|
96
|
+
* Sends SIGUSR1 to the mic-vpio process which clears pending audio
|
|
97
|
+
* and starts discarding any stale PCM data remaining in the OS pipe buffer.
|
|
98
|
+
*/
|
|
99
|
+
function interruptPlayback(): void {
|
|
100
|
+
if (vpioProcess) {
|
|
101
|
+
vpioProcess.kill("SIGUSR1");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resume speaker playback after an interrupt.
|
|
107
|
+
* Sends SIGUSR2 to the mic-vpio process which stops discarding stdin data,
|
|
108
|
+
* allowing new PCM audio to flow through to the ring buffer and speakers.
|
|
109
|
+
* Must be called before writing new audio after an interrupt.
|
|
110
|
+
*/
|
|
111
|
+
function resumePlayback(): void {
|
|
112
|
+
if (vpioProcess) {
|
|
113
|
+
vpioProcess.kill("SIGUSR2");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Stop the VPIO audio I/O process and free resources.
|
|
119
|
+
*/
|
|
120
|
+
function stopCapture(): void {
|
|
121
|
+
if (!vpioProcess) return;
|
|
122
|
+
vpioProcess.kill();
|
|
123
|
+
vpioProcess = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Returns whether audio I/O is currently active.
|
|
128
|
+
*
|
|
129
|
+
* @returns true if the VPIO process is running
|
|
130
|
+
*/
|
|
131
|
+
function isCapturing(): boolean {
|
|
132
|
+
return vpioProcess !== null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// HELPER FUNCTIONS
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wait for the mic-vpio binary to print READY on stderr.
|
|
141
|
+
*
|
|
142
|
+
* @param proc - The mic-vpio child process
|
|
143
|
+
* @throws Error if the process exits or times out before READY
|
|
144
|
+
*/
|
|
145
|
+
function waitForReady(proc: ChildProcess): Promise<void> {
|
|
146
|
+
return new Promise<void>((resolve, reject) => {
|
|
147
|
+
let stderrBuffer = "";
|
|
148
|
+
|
|
149
|
+
const timeout = setTimeout(() => {
|
|
150
|
+
reject(new Error(`mic-vpio did not become ready within ${READY_TIMEOUT_MS}ms`));
|
|
151
|
+
}, READY_TIMEOUT_MS);
|
|
152
|
+
|
|
153
|
+
const onData = (data: Buffer) => {
|
|
154
|
+
const text = data.toString();
|
|
155
|
+
stderrBuffer += text;
|
|
156
|
+
|
|
157
|
+
// Log non-READY stderr output (errors, diagnostics)
|
|
158
|
+
for (const line of text.split("\n")) {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
if (trimmed && trimmed !== "READY") {
|
|
161
|
+
console.log(`[mic-vpio] ${trimmed}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (stderrBuffer.includes("READY")) {
|
|
166
|
+
clearTimeout(timeout);
|
|
167
|
+
proc.stderr!.off("data", onData);
|
|
168
|
+
|
|
169
|
+
// Continue logging stderr after READY
|
|
170
|
+
proc.stderr!.on("data", (d: Buffer) => {
|
|
171
|
+
for (const line of d.toString().split("\n")) {
|
|
172
|
+
const trimmed = line.trim();
|
|
173
|
+
if (trimmed) console.log(`[mic-vpio] ${trimmed}`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
resolve();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
proc.stderr!.on("data", onData);
|
|
182
|
+
|
|
183
|
+
proc.on("error", (err) => {
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
reject(new Error(
|
|
186
|
+
`mic-vpio failed to start: ${err.message}. ` +
|
|
187
|
+
`Compile with: swiftc -O -o sidecar/mic-vpio sidecar/mic-vpio.swift -framework AudioToolbox -framework CoreAudio`
|
|
188
|
+
));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
proc.on("exit", (code) => {
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
reject(new Error(`mic-vpio exited with code ${code} before READY`));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Converts a raw 16-bit signed PCM buffer to a Float32Array normalized to -1.0..1.0.
|
|
200
|
+
*
|
|
201
|
+
* Each pair of bytes in the buffer represents one 16-bit signed little-endian sample.
|
|
202
|
+
* The normalized value is computed as: sample / 32768.0
|
|
203
|
+
*
|
|
204
|
+
* @param buffer - Raw 16-bit signed PCM buffer from the mic stream
|
|
205
|
+
* @returns Float32Array with values in the range -1.0 to 1.0
|
|
206
|
+
*/
|
|
207
|
+
function bufferToFloat32(buffer: Buffer): Float32Array {
|
|
208
|
+
const sampleCount = buffer.length / BYTES_PER_SAMPLE;
|
|
209
|
+
const float32 = new Float32Array(sampleCount);
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
212
|
+
const sample = buffer.readInt16LE(i * BYTES_PER_SAMPLE);
|
|
213
|
+
float32[i] = sample / PCM_16BIT_MAX;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return float32;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { startCapture, stopCapture, interruptPlayback, resumePlayback, isCapturing, bufferToFloat32 };
|
|
220
|
+
export type { AudioIO };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that the AudioWorklet processor plays back all TTS audio without
|
|
3
|
+
* dropping samples, regardless of chunk size or arrival timing.
|
|
4
|
+
*
|
|
5
|
+
* Loads the actual audio-processor.js and exercises it through the same
|
|
6
|
+
* postMessage/process interface the browser uses. Tests outcomes only --
|
|
7
|
+
* no assumptions about internal buffering strategy.
|
|
8
|
+
*
|
|
9
|
+
* Run: npx tsx --test sidecar/browser-audio-playback.test.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { strict as assert } from "node:assert";
|
|
14
|
+
import { readFileSync } from "fs";
|
|
15
|
+
import { join, dirname } from "path";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// HARNESS -- stub browser AudioWorklet APIs so we can load audio-processor.js
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
function loadProcessor(): {
|
|
25
|
+
postMessage: (data: Record<string, unknown>) => void;
|
|
26
|
+
process: (inputs: Float32Array[][], outputs: Float32Array[][]) => boolean;
|
|
27
|
+
} {
|
|
28
|
+
const source = readFileSync(join(__dirname, "../dashboard/public/audio-processor.js"), "utf-8");
|
|
29
|
+
|
|
30
|
+
let ProcessorClass: any;
|
|
31
|
+
|
|
32
|
+
// Stub globals that audio-processor.js expects
|
|
33
|
+
const globals = {
|
|
34
|
+
AudioWorkletProcessor: class {
|
|
35
|
+
port = {
|
|
36
|
+
onmessage: null as ((event: { data: Record<string, unknown> }) => void) | null,
|
|
37
|
+
postMessage(_data: unknown) {},
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
registerProcessor(_name: string, cls: any) {
|
|
41
|
+
ProcessorClass = cls;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const fn = new Function(...Object.keys(globals), source);
|
|
46
|
+
fn(...Object.values(globals));
|
|
47
|
+
|
|
48
|
+
const instance = new ProcessorClass();
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
postMessage(data: Record<string, unknown>) {
|
|
52
|
+
instance.port.onmessage?.({ data });
|
|
53
|
+
},
|
|
54
|
+
process(inputs: Float32Array[][], outputs: Float32Array[][]) {
|
|
55
|
+
return instance.process(inputs, outputs, {});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// TESTS
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Simulates the exact scenario from the logs:
|
|
66
|
+
* chunk 0: 2.0s audio at 24kHz -> 96,000 samples at 48kHz
|
|
67
|
+
* chunk 1: 3.0s audio at 24kHz -> 144,000 samples at 48kHz
|
|
68
|
+
*
|
|
69
|
+
* Both chunks arrive within ~500ms. The process() callback drains 128
|
|
70
|
+
* samples per frame. Between the two chunk arrivals, only ~24,000 samples
|
|
71
|
+
* drain -- far less than the total audio.
|
|
72
|
+
*
|
|
73
|
+
* All 240,000 samples should be played back with no drops.
|
|
74
|
+
*/
|
|
75
|
+
test("all TTS audio plays back without drops across multi-second chunks", () => {
|
|
76
|
+
const proc = loadProcessor();
|
|
77
|
+
const BROWSER_RATE = 48_000;
|
|
78
|
+
const FRAME_SIZE = 128;
|
|
79
|
+
|
|
80
|
+
// Chunk 0: 2s at 48kHz, filled with 0.5
|
|
81
|
+
const chunk0 = new Float32Array(2.0 * BROWSER_RATE);
|
|
82
|
+
chunk0.fill(0.5);
|
|
83
|
+
|
|
84
|
+
// Chunk 1: 3s at 48kHz, filled with 0.3
|
|
85
|
+
const chunk1 = new Float32Array(3.0 * BROWSER_RATE);
|
|
86
|
+
chunk1.fill(0.3);
|
|
87
|
+
|
|
88
|
+
const totalSamples = chunk0.length + chunk1.length; // 240,000
|
|
89
|
+
|
|
90
|
+
// Post chunk 0
|
|
91
|
+
proc.postMessage({ type: "playback", samples: chunk0 });
|
|
92
|
+
|
|
93
|
+
// Simulate ~500ms of process() draining between chunk arrivals
|
|
94
|
+
const framesBetweenChunks = Math.floor((0.5 * BROWSER_RATE) / FRAME_SIZE);
|
|
95
|
+
let totalNonSilent = 0;
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < framesBetweenChunks; i++) {
|
|
98
|
+
const output = new Float32Array(FRAME_SIZE);
|
|
99
|
+
proc.process([[new Float32Array(FRAME_SIZE)]], [[output]]);
|
|
100
|
+
for (let j = 0; j < output.length; j++) {
|
|
101
|
+
if (output[j] !== 0) totalNonSilent++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Post chunk 1
|
|
106
|
+
proc.postMessage({ type: "playback", samples: chunk1 });
|
|
107
|
+
|
|
108
|
+
// Drain until we get a full frame of silence (queue exhausted)
|
|
109
|
+
let silentFrames = 0;
|
|
110
|
+
while (silentFrames < 3) {
|
|
111
|
+
const output = new Float32Array(FRAME_SIZE);
|
|
112
|
+
proc.process([[new Float32Array(FRAME_SIZE)]], [[output]]);
|
|
113
|
+
|
|
114
|
+
let frameSilent = true;
|
|
115
|
+
for (let j = 0; j < output.length; j++) {
|
|
116
|
+
if (output[j] !== 0) {
|
|
117
|
+
totalNonSilent++;
|
|
118
|
+
frameSilent = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
silentFrames = frameSilent ? silentFrames + 1 : 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
assert.equal(
|
|
125
|
+
totalNonSilent, totalSamples,
|
|
126
|
+
`Expected all ${totalSamples} samples (${(totalSamples / BROWSER_RATE).toFixed(1)}s) to play back, ` +
|
|
127
|
+
`but only ${totalNonSilent} (${(totalNonSilent / BROWSER_RATE).toFixed(1)}s) were non-silent. ` +
|
|
128
|
+
`${totalSamples - totalNonSilent} samples were dropped.`
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Verifies that "clear" discards all pending audio immediately.
|
|
134
|
+
* After clear, process() should output silence.
|
|
135
|
+
*/
|
|
136
|
+
test("clear discards all pending audio", () => {
|
|
137
|
+
const proc = loadProcessor();
|
|
138
|
+
const FRAME_SIZE = 128;
|
|
139
|
+
|
|
140
|
+
proc.postMessage({ type: "playback", samples: new Float32Array(100_000).fill(0.5) });
|
|
141
|
+
proc.postMessage({ type: "clear" });
|
|
142
|
+
|
|
143
|
+
const output = new Float32Array(FRAME_SIZE);
|
|
144
|
+
proc.process([[new Float32Array(FRAME_SIZE)]], [[output]]);
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < output.length; i++) {
|
|
147
|
+
assert.equal(output[i], 0, `Expected silence at index ${i} after clear`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser audio adapter for direct WebSocket connections.
|
|
3
|
+
*
|
|
4
|
+
* Implements the AudioAdapter interface for browser-based voice calls by
|
|
5
|
+
* exchanging raw PCM audio over a WebSocket. Simpler than TwilioAudioAdapter --
|
|
6
|
+
* no mulaw codec, no Twilio-specific protocol framing.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Receive Float32Array PCM at 16kHz from the browser via binary WebSocket messages
|
|
10
|
+
* - Send int16 24kHz PCM as binary WebSocket messages to the browser
|
|
11
|
+
* - Handle backpressure on writeSpeaker via ws.send callback
|
|
12
|
+
* - Send JSON control messages (e.g. "clear" for interruption)
|
|
13
|
+
* - Cache the ready chime as 24kHz PCM for playback
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { WebSocket } from "ws";
|
|
17
|
+
import type { AudioAdapter } from "./audio-adapter.js";
|
|
18
|
+
|
|
19
|
+
import { decodeChimeToPcm } from "./chime.js";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// TYPES
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/** Configuration for creating a browser audio adapter */
|
|
26
|
+
export interface BrowserAudioAdapterConfig {
|
|
27
|
+
/** Active WebSocket connection to the browser */
|
|
28
|
+
ws: WebSocket;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// MAIN ENTRYPOINT
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create an AudioAdapter that reads/writes audio over a browser WebSocket connection.
|
|
37
|
+
*
|
|
38
|
+
* Decodes the macOS Glass.aiff chime to raw 24kHz PCM during initialization
|
|
39
|
+
* and caches the buffer for playChime(). The browser sends Float32Array PCM at
|
|
40
|
+
* 16kHz as binary messages, and receives int16 24kHz PCM as binary messages.
|
|
41
|
+
*
|
|
42
|
+
* @param config - Browser WebSocket connection
|
|
43
|
+
* @returns An AudioAdapter for browser audio I/O
|
|
44
|
+
*/
|
|
45
|
+
export function createBrowserAudioAdapter(config: BrowserAudioAdapterConfig): AudioAdapter {
|
|
46
|
+
const { ws } = config;
|
|
47
|
+
|
|
48
|
+
let wsClosed = false;
|
|
49
|
+
|
|
50
|
+
// Track WebSocket close state
|
|
51
|
+
ws.on("close", () => {
|
|
52
|
+
wsClosed = true;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Decode chime to raw 24kHz PCM and cache it
|
|
56
|
+
const chimePcm = decodeChimeToPcm();
|
|
57
|
+
|
|
58
|
+
// --------------------------------------------------------------------------
|
|
59
|
+
// AudioAdapter methods
|
|
60
|
+
// --------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Subscribe to incoming audio chunks from the browser.
|
|
64
|
+
* Registers a WebSocket binary message handler that converts the incoming
|
|
65
|
+
* Buffer to Float32Array and invokes the callback. Ignores text (JSON) messages.
|
|
66
|
+
*
|
|
67
|
+
* @param callback - Called with each audio chunk as Float32Array (16kHz)
|
|
68
|
+
*/
|
|
69
|
+
function onAudio(callback: (samples: Float32Array) => void): void {
|
|
70
|
+
ws.on("message", (data: Buffer | string, isBinary: boolean) => {
|
|
71
|
+
if (wsClosed) return;
|
|
72
|
+
|
|
73
|
+
// Only process binary messages (audio data)
|
|
74
|
+
if (!isBinary) return;
|
|
75
|
+
|
|
76
|
+
// Convert Buffer to Float32Array (copy to ensure 4-byte alignment)
|
|
77
|
+
const buffer = data as Buffer;
|
|
78
|
+
const aligned = new ArrayBuffer(buffer.byteLength);
|
|
79
|
+
new Uint8Array(aligned).set(buffer);
|
|
80
|
+
const float32 = new Float32Array(aligned);
|
|
81
|
+
callback(float32);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write PCM audio to the browser via WebSocket.
|
|
87
|
+
* Sends 24kHz int16 PCM buffer as a binary WebSocket message.
|
|
88
|
+
* Uses ws.send callback for backpressure -- resolves when the data is flushed.
|
|
89
|
+
* Silently returns if the WebSocket has closed.
|
|
90
|
+
*
|
|
91
|
+
* @param pcm - Raw PCM buffer (16-bit signed, 24kHz mono)
|
|
92
|
+
* @returns Resolves when the write completes
|
|
93
|
+
*/
|
|
94
|
+
function writeSpeaker(pcm: Buffer): Promise<void> {
|
|
95
|
+
if (wsClosed) return Promise.resolve();
|
|
96
|
+
|
|
97
|
+
return new Promise<void>((resolve) => {
|
|
98
|
+
ws.send(pcm, { binary: true }, () => {
|
|
99
|
+
// Resolve on both success and error -- write errors mean the
|
|
100
|
+
// connection is closing, and callers should not need to handle that
|
|
101
|
+
resolve();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clear the browser's playback buffer immediately (user interruption).
|
|
108
|
+
* Sends a JSON "clear" message over the WebSocket.
|
|
109
|
+
*/
|
|
110
|
+
function interrupt(): void {
|
|
111
|
+
if (wsClosed) return;
|
|
112
|
+
|
|
113
|
+
ws.send(JSON.stringify({ type: "clear" }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resume output after an interrupt. No-op for browser --
|
|
118
|
+
* AudioWorklet resumes consuming from ring buffer automatically after clear.
|
|
119
|
+
*/
|
|
120
|
+
function resume(): void {
|
|
121
|
+
// No-op: browser AudioWorklet resumes automatically
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Play the ready chime by sending the cached 24kHz PCM through writeSpeaker.
|
|
126
|
+
*/
|
|
127
|
+
function playChime(): void {
|
|
128
|
+
writeSpeaker(chimePcm);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Clean up resources. No-op for browser -- WebSocket lifecycle is
|
|
133
|
+
* managed by browser-server.ts.
|
|
134
|
+
*/
|
|
135
|
+
function destroy(): void {
|
|
136
|
+
// No-op: WebSocket lifecycle managed by browser-server.ts
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
onAudio,
|
|
141
|
+
writeSpeaker,
|
|
142
|
+
interrupt,
|
|
143
|
+
resume,
|
|
144
|
+
playChime,
|
|
145
|
+
destroy,
|
|
146
|
+
};
|
|
147
|
+
}
|