@vrealtime/server 0.0.0-stage → 0.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.
- package/LICENSE +201 -0
- package/README.md +4 -2
- package/dist/capabilities.d.ts +13 -0
- package/dist/capabilities.js +11 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +480 -0
- package/dist/node/debug-page.d.ts +6 -0
- package/dist/node/debug-page.js +181 -0
- package/dist/node/index.d.ts +23 -0
- package/dist/node/index.js +120 -0
- package/dist/tool-stream.d.ts +8 -0
- package/dist/tool-stream.js +29 -0
- package/dist/workers/index.d.ts +15 -0
- package/dist/workers/index.js +37 -0
- package/package.json +62 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { startSession, } from '@vrealtime/core';
|
|
2
|
+
import { Inbox, Outbox, decodeAudioFrame, decodeClientMessage, encodeAudioFrame, encodeMessage, negotiateCapabilities, PROTOCOL_VERSION, TOOL_STREAM_V1, } from '@vrealtime/protocol';
|
|
3
|
+
import { supportedCapabilities } from './capabilities.js';
|
|
4
|
+
import { legacyPreview, toolStreamMessage } from './tool-stream.js';
|
|
5
|
+
const PCM24 = { encoding: 'pcm16', sampleRate: 24000, channels: 1 };
|
|
6
|
+
const REFUSED = Symbol('refused');
|
|
7
|
+
const FLUSH_INTERVAL_MS = 10;
|
|
8
|
+
const OUTPUT = 2;
|
|
9
|
+
const INPUT = 1;
|
|
10
|
+
const systemClock = {
|
|
11
|
+
now: () => performance.now(),
|
|
12
|
+
setTimeout: (run, ms) => setTimeout(run, ms),
|
|
13
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
14
|
+
};
|
|
15
|
+
export function toCoreFormat(format) {
|
|
16
|
+
if (format === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (format.encoding === 'pcmu')
|
|
19
|
+
return { encoding: 'pcmu' };
|
|
20
|
+
if (format.encoding === 'opus')
|
|
21
|
+
return { encoding: 'opus', frameMs: 20 };
|
|
22
|
+
const rate = format.sampleRate;
|
|
23
|
+
return rate === 8000 || rate === 16000 || rate === 24000 || rate === 48000 ? { encoding: 'pcm16', sampleRate: rate, channels: 1 } : undefined;
|
|
24
|
+
}
|
|
25
|
+
export function toWireFormat(format) {
|
|
26
|
+
return format.encoding === 'pcm16' ? { encoding: 'pcm16', sampleRate: format.sampleRate } : { encoding: format.encoding };
|
|
27
|
+
}
|
|
28
|
+
function sameFormat(a, b) {
|
|
29
|
+
return a.encoding === b.encoding && (a.encoding !== 'pcm16' || (b.encoding === 'pcm16' && a.sampleRate === b.sampleRate));
|
|
30
|
+
}
|
|
31
|
+
/** Use the app's format when the model takes it as-is (pass-through), otherwise 24 kHz PCM16. */
|
|
32
|
+
function negotiate(requested, supported) {
|
|
33
|
+
const format = toCoreFormat(requested);
|
|
34
|
+
if (format && supported.some((s) => sameFormat(s, format)))
|
|
35
|
+
return format;
|
|
36
|
+
return supported.find((s) => sameFormat(s, PCM24)) ?? supported[0] ?? PCM24;
|
|
37
|
+
}
|
|
38
|
+
/** One agent session and everything that must survive a reconnect. */
|
|
39
|
+
class LiveSession {
|
|
40
|
+
hub;
|
|
41
|
+
principal;
|
|
42
|
+
id = crypto.randomUUID();
|
|
43
|
+
outbox = new Outbox();
|
|
44
|
+
inbox = new Inbox();
|
|
45
|
+
session;
|
|
46
|
+
input = PCM24;
|
|
47
|
+
output = PCM24;
|
|
48
|
+
/** Negotiated when the session starts; fixed for its lifetime, resumes included. */
|
|
49
|
+
capabilities = [];
|
|
50
|
+
socket;
|
|
51
|
+
expiry;
|
|
52
|
+
#audioSeq = 0;
|
|
53
|
+
#audioItem;
|
|
54
|
+
#queue = [];
|
|
55
|
+
#flushTimer;
|
|
56
|
+
constructor(hub, principal) {
|
|
57
|
+
this.hub = hub;
|
|
58
|
+
this.principal = principal;
|
|
59
|
+
}
|
|
60
|
+
bind(agentSession) {
|
|
61
|
+
this.session = agentSession;
|
|
62
|
+
agentSession.on('turn.state', (e) => this.send({ type: 'turn.state', turnId: e.turnId, epoch: e.epoch, state: e.state }));
|
|
63
|
+
agentSession.on('transcript', (e) => this.send({ type: 'transcript', role: e.role, itemId: e.itemId, text: e.text, final: e.final }));
|
|
64
|
+
agentSession.on('text', (e) => this.send({ type: 'text', responseId: e.responseId, delta: e.delta }));
|
|
65
|
+
agentSession.on('audio', (e) => this.#audio(e));
|
|
66
|
+
// `arguments` is a required member on the wire: a missing value is sent as null.
|
|
67
|
+
agentSession.on('tool.call', (e) => this.send({ type: 'tool.call', callId: e.callId, epoch: e.epoch, turnId: e.turnId, name: e.name, arguments: e.arguments ?? null }));
|
|
68
|
+
// One source for call progress: the full lifecycle with tool-stream-v1, the 1.0 preview without it, never both.
|
|
69
|
+
const toolStream = this.capabilities.includes(TOOL_STREAM_V1);
|
|
70
|
+
agentSession.on('tool.stream', (e) => {
|
|
71
|
+
const message = toolStream ? toolStreamMessage(e) : legacyPreview(e);
|
|
72
|
+
if (message)
|
|
73
|
+
this.send(message);
|
|
74
|
+
});
|
|
75
|
+
agentSession.on('interrupted', (e) => {
|
|
76
|
+
this.#dropAudio(e.epoch);
|
|
77
|
+
this.send({ type: 'interrupted', epoch: e.epoch });
|
|
78
|
+
});
|
|
79
|
+
agentSession.on('error', (e) => this.send({ type: 'error', code: 'AGENT_ERROR', message: e.error.message, recoverable: e.recoverable }));
|
|
80
|
+
agentSession.on('closed', () => {
|
|
81
|
+
this.hub.forget(this);
|
|
82
|
+
this.socket?.close(1000, 'session closed');
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
send(message, epoch) {
|
|
86
|
+
const stamped = this.outbox.stamp(message);
|
|
87
|
+
this.#write(encodeMessage(stamped), epoch);
|
|
88
|
+
}
|
|
89
|
+
/** Opens the session on the bound socket; `resumedFrom` is set on a resume. */
|
|
90
|
+
sendReady(resumedFrom) {
|
|
91
|
+
this.send({
|
|
92
|
+
type: 'ready',
|
|
93
|
+
sessionId: this.id,
|
|
94
|
+
input: toWireFormat(this.input),
|
|
95
|
+
output: toWireFormat(this.output),
|
|
96
|
+
...(resumedFrom !== undefined ? { resumedFrom } : {}),
|
|
97
|
+
...(this.capabilities.length > 0 ? { capabilities: [...this.capabilities] } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Re-sends everything after the client's last seen seq. False when that is no longer possible. */
|
|
101
|
+
resend(lastSeen) {
|
|
102
|
+
const missed = this.outbox.resendFrom(lastSeen);
|
|
103
|
+
if (missed === undefined)
|
|
104
|
+
return false;
|
|
105
|
+
for (const message of missed)
|
|
106
|
+
this.#write(encodeMessage(message));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
detach() {
|
|
110
|
+
this.socket = undefined;
|
|
111
|
+
this.#queue = [];
|
|
112
|
+
this.#audioItem = undefined;
|
|
113
|
+
}
|
|
114
|
+
flush() {
|
|
115
|
+
this.#flushTimer = undefined;
|
|
116
|
+
const socket = this.socket;
|
|
117
|
+
if (!socket) {
|
|
118
|
+
this.#queue = [];
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
while (this.#queue.length > 0 && !socket.congested())
|
|
122
|
+
socket.send(this.#queue.shift().data);
|
|
123
|
+
if (this.#queue.length > 0)
|
|
124
|
+
this.#scheduleFlush();
|
|
125
|
+
}
|
|
126
|
+
#audio(event) {
|
|
127
|
+
if (!this.socket)
|
|
128
|
+
return;
|
|
129
|
+
if (event.itemId !== this.#audioItem) {
|
|
130
|
+
this.#audioItem = event.itemId;
|
|
131
|
+
this.send({ type: 'audio.start', itemId: event.itemId, responseId: event.responseId, epoch: event.epoch }, event.epoch);
|
|
132
|
+
}
|
|
133
|
+
this.#write(encodeAudioFrame(OUTPUT, this.#audioSeq++, event.chunk.data), event.epoch);
|
|
134
|
+
}
|
|
135
|
+
/** Frames keep their order: once anything is queued, everything queues behind it. */
|
|
136
|
+
#write(data, epoch) {
|
|
137
|
+
const socket = this.socket;
|
|
138
|
+
if (!socket)
|
|
139
|
+
return;
|
|
140
|
+
if (this.#queue.length === 0 && !socket.congested()) {
|
|
141
|
+
socket.send(data);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.#queue.push(epoch === undefined ? { data } : { data, epoch });
|
|
145
|
+
this.#scheduleFlush();
|
|
146
|
+
}
|
|
147
|
+
#dropAudio(closedEpoch) {
|
|
148
|
+
this.#queue = this.#queue.filter((item) => item.epoch === undefined || item.epoch > closedEpoch);
|
|
149
|
+
this.#audioItem = undefined;
|
|
150
|
+
}
|
|
151
|
+
#scheduleFlush() {
|
|
152
|
+
if (this.#flushTimer === undefined)
|
|
153
|
+
this.#flushTimer = this.hub.clock.setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
class SocketConnection {
|
|
157
|
+
hub;
|
|
158
|
+
socket;
|
|
159
|
+
headers;
|
|
160
|
+
#live;
|
|
161
|
+
#starting;
|
|
162
|
+
#pending = [];
|
|
163
|
+
#closed = false;
|
|
164
|
+
#lastSeen;
|
|
165
|
+
#heartbeat;
|
|
166
|
+
#ackScheduled = false;
|
|
167
|
+
constructor(hub, socket, headers) {
|
|
168
|
+
this.hub = hub;
|
|
169
|
+
this.socket = socket;
|
|
170
|
+
this.headers = headers;
|
|
171
|
+
this.#lastSeen = hub.clock.now();
|
|
172
|
+
this.#checkHeartbeat();
|
|
173
|
+
socket.addEventListener?.('message', (event) => this.receive(event.data));
|
|
174
|
+
socket.addEventListener?.('close', () => this.closed());
|
|
175
|
+
}
|
|
176
|
+
get sessionId() {
|
|
177
|
+
return this.#live?.id;
|
|
178
|
+
}
|
|
179
|
+
congested() {
|
|
180
|
+
return (this.socket.bufferedAmount ?? 0) > this.hub.highWaterMark;
|
|
181
|
+
}
|
|
182
|
+
send(data) {
|
|
183
|
+
if (!this.#closed)
|
|
184
|
+
this.socket.send(data);
|
|
185
|
+
}
|
|
186
|
+
close(code, reason) {
|
|
187
|
+
if (this.#closed)
|
|
188
|
+
return;
|
|
189
|
+
this.socket.close(code, reason);
|
|
190
|
+
this.closed();
|
|
191
|
+
}
|
|
192
|
+
receive(data) {
|
|
193
|
+
if (this.#closed)
|
|
194
|
+
return;
|
|
195
|
+
this.#lastSeen = this.hub.clock.now();
|
|
196
|
+
if (typeof data !== 'string') {
|
|
197
|
+
this.#receiveAudio(data);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const decoded = decodeClientMessage(data);
|
|
201
|
+
if (!decoded.ok) {
|
|
202
|
+
this.#error(decoded.error.code, decoded.error.message, true);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const message = decoded.message;
|
|
206
|
+
if (message.type === 'ping') {
|
|
207
|
+
this.#sendDirect({ type: 'pong' });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (message.type === 'hello') {
|
|
211
|
+
void this.#hello(message);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (this.#starting || !this.#live) {
|
|
215
|
+
if (this.#starting)
|
|
216
|
+
this.#pending.push(message);
|
|
217
|
+
else
|
|
218
|
+
this.#error('NO_SESSION', 'Send hello first', false);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.#handle(this.#live, message);
|
|
222
|
+
}
|
|
223
|
+
closed() {
|
|
224
|
+
if (this.#closed)
|
|
225
|
+
return;
|
|
226
|
+
this.#closed = true;
|
|
227
|
+
this.hub.clock.clearTimeout(this.#heartbeat);
|
|
228
|
+
const live = this.#live;
|
|
229
|
+
if (live?.socket === this) {
|
|
230
|
+
live.detach();
|
|
231
|
+
this.hub.park(live);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
#receiveAudio(data) {
|
|
235
|
+
const bytes = data instanceof Uint8Array ? data : data instanceof ArrayBuffer ? new Uint8Array(data) : undefined;
|
|
236
|
+
const frame = bytes && decodeAudioFrame(bytes);
|
|
237
|
+
if (!frame || frame.kind !== INPUT) {
|
|
238
|
+
this.#error('MALFORMED', 'Invalid audio frame', true);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const live = this.#live;
|
|
242
|
+
if (live && !this.#starting)
|
|
243
|
+
live.session.sendAudio({ data: frame.payload, format: live.input });
|
|
244
|
+
}
|
|
245
|
+
async #hello(hello) {
|
|
246
|
+
if (this.#live || this.#starting) {
|
|
247
|
+
this.#error('DUPLICATE_HELLO', 'hello was already received', true);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Messages that arrive while authenticating or starting are queued in order.
|
|
251
|
+
let done;
|
|
252
|
+
this.#starting = new Promise((resolve) => { done = resolve; });
|
|
253
|
+
try {
|
|
254
|
+
const principal = await this.#authenticate(hello);
|
|
255
|
+
if (this.#closed)
|
|
256
|
+
return;
|
|
257
|
+
if (principal === REFUSED) {
|
|
258
|
+
this.#error('UNAUTHORIZED', 'Authentication failed', false);
|
|
259
|
+
this.close(4401, 'unauthorized');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const live = hello.sessionId !== undefined ? this.#resume(hello, principal) : await this.#start(hello, principal);
|
|
263
|
+
if (!live)
|
|
264
|
+
return;
|
|
265
|
+
if (hello.context !== undefined)
|
|
266
|
+
this.hub.options.onContext?.(hello.context, live.session);
|
|
267
|
+
for (const message of this.#pending.splice(0))
|
|
268
|
+
this.#handle(live, message);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
this.#starting = undefined;
|
|
272
|
+
done();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async #authenticate(hello) {
|
|
276
|
+
const authenticate = this.hub.options.authenticate;
|
|
277
|
+
if (!authenticate)
|
|
278
|
+
return undefined;
|
|
279
|
+
try {
|
|
280
|
+
const principal = await authenticate({ token: hello.auth, context: hello.context, sessionId: hello.sessionId, headers: this.headers });
|
|
281
|
+
return principal ? principal : REFUSED;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return REFUSED;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async #start(hello, principal) {
|
|
288
|
+
const live = new LiveSession(this.hub, principal);
|
|
289
|
+
const supported = this.hub.agent.model.capabilities;
|
|
290
|
+
live.input = negotiate(hello.input, supported.input.formats);
|
|
291
|
+
live.output = negotiate(hello.output, supported.output.formats);
|
|
292
|
+
live.capabilities = negotiateCapabilities(hello.capabilities, this.hub.capabilities);
|
|
293
|
+
try {
|
|
294
|
+
const configured = (await this.hub.options.configure?.(principal, { context: hello.context })) ?? {};
|
|
295
|
+
const session = await startSession(this.hub.agent, {
|
|
296
|
+
clientTools: hello.tools,
|
|
297
|
+
config: { ...configured, inputFormat: live.input, outputFormat: live.output },
|
|
298
|
+
clock: this.hub.clock,
|
|
299
|
+
data: principal,
|
|
300
|
+
});
|
|
301
|
+
live.bind(session);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
this.#error('START_FAILED', error instanceof Error ? error.message : String(error), false);
|
|
305
|
+
this.close(1011, 'session start failed');
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
this.hub.adopt(live);
|
|
309
|
+
if (this.#closed) {
|
|
310
|
+
// The socket went away while the model was connecting; park so a resume can still find it.
|
|
311
|
+
this.hub.park(live);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
this.#bind(live);
|
|
315
|
+
live.sendReady();
|
|
316
|
+
live.resend(0); // anything the session emitted before the socket was bound
|
|
317
|
+
return live;
|
|
318
|
+
}
|
|
319
|
+
#resume(hello, principal) {
|
|
320
|
+
const live = this.hub.find(hello.sessionId);
|
|
321
|
+
// An unknown session and someone else's session get the same answer.
|
|
322
|
+
if (!live || live.principal?.id !== principal?.id) {
|
|
323
|
+
this.#error('RESUME_FAILED', 'Unknown or expired session', false);
|
|
324
|
+
this.close(4404, 'unknown session');
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
live.socket?.close(4000, 'replaced by a newer connection');
|
|
328
|
+
this.#bind(live);
|
|
329
|
+
if (live.session.closed)
|
|
330
|
+
return undefined;
|
|
331
|
+
live.sendReady(live.inbox.lastSeq);
|
|
332
|
+
if (!live.resend(hello.resumeFrom ?? 0)) {
|
|
333
|
+
this.#error('RESUME_FAILED', 'Cannot resend from resumeFrom: those messages were dropped or never sent', false);
|
|
334
|
+
void live.session.close();
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
return live;
|
|
338
|
+
}
|
|
339
|
+
#bind(live) {
|
|
340
|
+
this.hub.unpark(live);
|
|
341
|
+
this.#live = live;
|
|
342
|
+
live.socket = this;
|
|
343
|
+
}
|
|
344
|
+
#handle(live, message) {
|
|
345
|
+
if (message.type === 'ack') {
|
|
346
|
+
live.outbox.ack(message.upTo);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!live.inbox.accept(message)) {
|
|
350
|
+
this.#scheduleAck(live);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const session = live.session;
|
|
354
|
+
switch (message.type) {
|
|
355
|
+
case 'text':
|
|
356
|
+
session.sendText(message.text);
|
|
357
|
+
break;
|
|
358
|
+
case 'interrupt':
|
|
359
|
+
session.interrupt(message.reason);
|
|
360
|
+
break;
|
|
361
|
+
case 'playback':
|
|
362
|
+
session.playback(message.itemId, message.playedMs);
|
|
363
|
+
break;
|
|
364
|
+
case 'tool.result': {
|
|
365
|
+
const result = message.ok
|
|
366
|
+
? { ok: true, output: message.output }
|
|
367
|
+
: { ok: false, code: message.code, message: message.message };
|
|
368
|
+
session.submitClientToolResult(message.callId, result);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case 'context':
|
|
372
|
+
this.hub.options.onContext?.(message.context, session);
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
this.#scheduleAck(live);
|
|
376
|
+
}
|
|
377
|
+
#scheduleAck(live) {
|
|
378
|
+
if (this.#ackScheduled)
|
|
379
|
+
return;
|
|
380
|
+
this.#ackScheduled = true;
|
|
381
|
+
queueMicrotask(() => {
|
|
382
|
+
this.#ackScheduled = false;
|
|
383
|
+
if (live.socket === this)
|
|
384
|
+
live.send({ type: 'ack', upTo: live.inbox.lastSeq });
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
#sendDirect(message) {
|
|
388
|
+
if (this.#live?.socket === this)
|
|
389
|
+
this.#live.send(message);
|
|
390
|
+
else
|
|
391
|
+
this.send(encodeMessage({ v: PROTOCOL_VERSION, seq: 0, ...message }));
|
|
392
|
+
}
|
|
393
|
+
#error(code, message, recoverable) {
|
|
394
|
+
this.#sendDirect({ type: 'error', code, message, recoverable });
|
|
395
|
+
}
|
|
396
|
+
#checkHeartbeat() {
|
|
397
|
+
const timeout = this.hub.heartbeatTimeoutMs;
|
|
398
|
+
this.#heartbeat = this.hub.clock.setTimeout(() => {
|
|
399
|
+
if (this.#closed)
|
|
400
|
+
return;
|
|
401
|
+
if (this.hub.clock.now() - this.#lastSeen >= timeout)
|
|
402
|
+
this.close(4408, 'heartbeat timeout');
|
|
403
|
+
else
|
|
404
|
+
this.#checkHeartbeat();
|
|
405
|
+
}, Math.max(1, timeout / 3));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Shares sessions between sockets, so a reconnecting app can resume. */
|
|
409
|
+
export class Hub {
|
|
410
|
+
agent;
|
|
411
|
+
options;
|
|
412
|
+
clock;
|
|
413
|
+
highWaterMark;
|
|
414
|
+
heartbeatTimeoutMs;
|
|
415
|
+
/** Protocol capabilities sessions may negotiate: what this server implements and the agent's model supports. */
|
|
416
|
+
capabilities;
|
|
417
|
+
#sessions = new Map();
|
|
418
|
+
constructor(agent, options = {}) {
|
|
419
|
+
this.agent = agent;
|
|
420
|
+
this.options = options;
|
|
421
|
+
this.capabilities = supportedCapabilities(agent.model.capabilities);
|
|
422
|
+
this.clock = options.clock ?? systemClock;
|
|
423
|
+
this.highWaterMark = options.highWaterMark ?? 256 * 1024;
|
|
424
|
+
this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 45_000;
|
|
425
|
+
}
|
|
426
|
+
/** `headers` (of the upgrade request) are passed to `authenticate`. */
|
|
427
|
+
attach(socket, info = {}) {
|
|
428
|
+
return new SocketConnection(this, socket, info.headers);
|
|
429
|
+
}
|
|
430
|
+
/** Live sessions, connected or waiting for a resume. */
|
|
431
|
+
get size() {
|
|
432
|
+
return this.#sessions.size;
|
|
433
|
+
}
|
|
434
|
+
async close() {
|
|
435
|
+
await Promise.all([...this.#sessions.values()].map((live) => live.session.close()));
|
|
436
|
+
this.#sessions.clear();
|
|
437
|
+
}
|
|
438
|
+
/** @internal */
|
|
439
|
+
adopt(live) {
|
|
440
|
+
this.#sessions.set(live.id, live);
|
|
441
|
+
}
|
|
442
|
+
/** @internal */
|
|
443
|
+
find(id) {
|
|
444
|
+
return this.#sessions.get(id);
|
|
445
|
+
}
|
|
446
|
+
/** @internal */
|
|
447
|
+
forget(live) {
|
|
448
|
+
this.unpark(live);
|
|
449
|
+
this.#sessions.delete(live.id);
|
|
450
|
+
}
|
|
451
|
+
/** @internal Keeps a socketless session for `resumeWindowMs`, then closes it. */
|
|
452
|
+
park(live) {
|
|
453
|
+
this.unpark(live);
|
|
454
|
+
live.expiry = this.clock.setTimeout(() => {
|
|
455
|
+
live.expiry = undefined;
|
|
456
|
+
if (!live.socket)
|
|
457
|
+
void live.session.close();
|
|
458
|
+
}, this.options.resumeWindowMs ?? 30_000);
|
|
459
|
+
}
|
|
460
|
+
/** @internal */
|
|
461
|
+
unpark(live) {
|
|
462
|
+
if (live.expiry !== undefined)
|
|
463
|
+
this.clock.clearTimeout(live.expiry);
|
|
464
|
+
live.expiry = undefined;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const hubs = new WeakMap();
|
|
468
|
+
/**
|
|
469
|
+
* Serves `agent` over one WebSocket using protocol v1. Sockets attached for the
|
|
470
|
+
* same agent share a hub, so a reconnecting app can resume its session.
|
|
471
|
+
* Options apply when the agent's hub is first created.
|
|
472
|
+
*/
|
|
473
|
+
export function attach(agent, socket, options, info) {
|
|
474
|
+
let hub = hubs.get(agent);
|
|
475
|
+
if (!hub) {
|
|
476
|
+
hub = new Hub(agent, options);
|
|
477
|
+
hubs.set(agent, hub);
|
|
478
|
+
}
|
|
479
|
+
return hub.attach(socket, info);
|
|
480
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microphone debug page served by `serve()` at `/`. A dependency-free protocol v1
|
|
3
|
+
* client: text and voice in, transcripts and audio out, a barge-in interrupt and
|
|
4
|
+
* one app-side tool (`set_accent_color`) behind an epoch gate.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEBUG_PAGE = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>vRealTime debug</title>\n<style>\n :root { --accent: #2563eb; --bg: #fff; --fg: #111827; --muted: #6b7280; --line: #e5e7eb; color-scheme: light dark; }\n @media (prefers-color-scheme: dark) { :root { --bg: #0b0f17; --fg: #e5e7eb; --muted: #9ca3af; --line: #1f2937; } }\n body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }\n main { max-width: 720px; margin: 0 auto; padding: 16px; }\n h1 { font-size: 20px; margin: 0 0 4px; color: var(--accent); }\n .muted { color: var(--muted); font-size: 13px; }\n .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 12px 0; }\n button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }\n button.secondary { background: transparent; color: var(--fg); border: 1px solid var(--line); }\n form { display: flex; gap: 8px; }\n input { flex: 1; min-width: 0; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; font: inherit; }\n #state { padding: 2px 8px; border-radius: 99px; border: 1px solid var(--accent); color: var(--accent); font-size: 13px; }\n #log { border-top: 1px solid var(--line); margin-top: 12px; padding-top: 8px; }\n #log p { margin: 6px 0; } .user { font-weight: 600; } .note { color: var(--muted); font-size: 13px; }\n</style>\n</head>\n<body>\n<main>\n <h1>vRealTime</h1>\n <div class=\"muted\" id=\"status\">connecting\u2026</div>\n <div class=\"row\">\n <button id=\"mic\">Start mic</button>\n <button id=\"stop\" class=\"secondary\">Interrupt</button>\n <span id=\"state\">idle</span>\n </div>\n <form id=\"form\"><input id=\"text\" placeholder=\"Type a message\" autocomplete=\"off\"><button>Send</button></form>\n <div id=\"log\"></div>\n</main>\n<script type=\"module\">\nconst RATE = 24000, FRAME = 480, TRANSIENT = ['hello', 'ack', 'ping'];\nconst $ = (id) => document.getElementById(id);\nlet seq = 0, lastSeq = 0, epoch = 0, closedEpoch = -1, audioSeq = 0;\n\nconst tools = {\n set_accent_color: {\n description: 'Set the page accent color to any CSS color.',\n parameters: { type: 'object', properties: { color: { type: 'string' } }, required: ['color'] },\n run: ({ color }) => { document.documentElement.style.setProperty('--accent', color); return { color }; },\n },\n};\n\nconst ws = new WebSocket(location.origin.replace(/^http/, 'ws') + location.pathname);\nws.binaryType = 'arraybuffer';\nconst send = (m) => { if (ws.readyState === 1) ws.send(JSON.stringify({ v: 1, seq: TRANSIENT.includes(m.type) ? 0 : ++seq, ...m })); };\nws.onopen = () => send({\n type: 'hello',\n tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, parameters: t.parameters })),\n input: { encoding: 'pcm16', sampleRate: RATE }, output: { encoding: 'pcm16', sampleRate: RATE },\n});\nws.onclose = (e) => { $('status').textContent = 'disconnected (' + e.code + ')'; };\nsetInterval(() => { send({ type: 'ping' }); if (lastSeq) send({ type: 'ack', upTo: lastSeq }); }, 10000);\n\nconst lines = new Map();\nfunction line(id, role, text) {\n let el = lines.get(id);\n if (!el) { el = document.createElement('p'); el.className = role; $('log').append(el); lines.set(id, el); }\n el.textContent = (role === 'user' ? 'You: ' : 'Agent: ') + text;\n el.scrollIntoView({ block: 'end' });\n}\nfunction note(text) { const el = document.createElement('p'); el.className = 'note'; el.textContent = text; $('log').append(el); }\n\n// Playback: schedule PCM16 chunks back to back; track what was actually heard per item.\nlet ctx, playhead = 0, sources = [], item;\nconst audio = () => (ctx ??= new AudioContext({ sampleRate: RATE }));\nfunction play(bytes) {\n const c = audio(), n = bytes.byteLength >> 1, view = new DataView(bytes.buffer, bytes.byteOffset, n * 2);\n const buffer = c.createBuffer(1, n, RATE), data = buffer.getChannelData(0);\n for (let i = 0; i < n; i++) data[i] = view.getInt16(i * 2, true) / 32768;\n const src = c.createBufferSource();\n src.buffer = buffer; src.connect(c.destination);\n const at = Math.max(playhead, c.currentTime + 0.02);\n if (item.start === undefined) item.start = at;\n src.start(at); playhead = at + buffer.duration; item.end = playhead;\n sources.push(src); src.onended = () => { sources = sources.filter((s) => s !== src); };\n}\nfunction stopPlayback() {\n for (const s of sources) s.stop();\n sources = []; playhead = 0;\n if (item && item.start !== undefined) {\n const heard = Math.max(0, Math.min(audio().currentTime, item.end) - item.start) * 1000;\n send({ type: 'playback', itemId: item.id, playedMs: Math.round(heard) });\n }\n item = undefined;\n}\nfunction interrupt() {\n closedEpoch = Math.max(closedEpoch, epoch);\n stopPlayback();\n send({ type: 'interrupt', epoch, reason: 'user' });\n}\n\nasync function runTool(m) {\n const tool = tools[m.name];\n if (m.epoch <= closedEpoch) return send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: false, code: 'INTERRUPTED', message: 'interrupted' });\n if (!tool) return send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: false, code: 'FAILED', message: 'unknown tool' });\n try {\n const output = await tool.run(m.arguments);\n send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: true, output });\n note('tool ' + m.name + ' ' + JSON.stringify(m.arguments));\n } catch (e) {\n send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: false, code: 'FAILED', message: String(e) });\n }\n}\n\nws.onmessage = (e) => {\n if (typeof e.data !== 'string') {\n const bytes = new Uint8Array(e.data);\n if (bytes[0] === 2 && item) play(bytes.subarray(5));\n return;\n }\n const m = JSON.parse(e.data);\n if (m.seq > 0) { if (m.seq <= lastSeq) return; lastSeq = m.seq; }\n switch (m.type) {\n case 'ready': $('status').textContent = 'session ' + m.sessionId; break;\n case 'turn.state':\n epoch = Math.max(epoch, m.epoch); $('state').textContent = m.state;\n if (m.state === 'listening' && sources.length) stopPlayback();\n break;\n case 'transcript': line(m.itemId, m.role, m.text); break;\n case 'text': line(m.responseId, 'agent', ((lines.get(m.responseId)?.textContent ?? 'Agent: ').slice(7)) + m.delta); break;\n case 'audio.start': item = m.epoch > closedEpoch ? { id: m.itemId } : undefined; break;\n case 'tool.call': runTool(m); break;\n case 'interrupted': closedEpoch = Math.max(closedEpoch, m.epoch); stopPlayback(); note('interrupted'); break;\n case 'error': note('error ' + m.code + ': ' + m.message); break;\n }\n};\n\n$('form').onsubmit = (e) => {\n e.preventDefault();\n const text = $('text').value.trim();\n if (!text) return;\n stopPlayback(); line('local-' + seq, 'user', text); send({ type: 'text', text }); $('text').value = '';\n};\n$('stop').onclick = interrupt;\n\n// Microphone: AudioWorklet \u2192 20 ms PCM16 frames \u2192 [kind=1][seq u32 BE][payload].\nconst WORKLET = `class Capture extends AudioWorkletProcessor {\n constructor() { super(); this.buf = new Int16Array(${FRAME}); this.n = 0; }\n process(inputs) {\n const ch = inputs[0][0];\n if (ch) for (let i = 0; i < ch.length; i++) {\n const s = Math.max(-1, Math.min(1, ch[i]));\n this.buf[this.n++] = s < 0 ? s * 32768 : s * 32767;\n if (this.n === ${FRAME}) { this.port.postMessage(this.buf.buffer.slice(0)); this.n = 0; }\n }\n return true;\n }\n}\nregisterProcessor('vrt-capture', Capture);`;\nlet mic;\n$('mic').onclick = async () => {\n if (mic) { mic.getTracks().forEach((t) => t.stop()); mic = undefined; $('mic').textContent = 'Start mic'; return; }\n const c = audio();\n await c.resume();\n mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 } });\n await c.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET], { type: 'text/javascript' })));\n const node = new AudioWorkletNode(c, 'vrt-capture');\n node.port.onmessage = ({ data }) => {\n if (!mic || ws.readyState !== 1) return;\n const frame = new Uint8Array(5 + data.byteLength);\n frame[0] = 1; new DataView(frame.buffer).setUint32(1, audioSeq++ >>> 0); frame.set(new Uint8Array(data), 5);\n ws.send(frame);\n };\n c.createMediaStreamSource(mic).connect(node);\n $('mic').textContent = 'Stop mic';\n};\n</script>\n</body>\n</html>\n";
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microphone debug page served by `serve()` at `/`. A dependency-free protocol v1
|
|
3
|
+
* client: text and voice in, transcripts and audio out, a barge-in interrupt and
|
|
4
|
+
* one app-side tool (`set_accent_color`) behind an epoch gate.
|
|
5
|
+
*/
|
|
6
|
+
export const DEBUG_PAGE = /* html */ `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
11
|
+
<title>vRealTime debug</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root { --accent: #2563eb; --bg: #fff; --fg: #111827; --muted: #6b7280; --line: #e5e7eb; color-scheme: light dark; }
|
|
14
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0b0f17; --fg: #e5e7eb; --muted: #9ca3af; --line: #1f2937; } }
|
|
15
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
|
16
|
+
main { max-width: 720px; margin: 0 auto; padding: 16px; }
|
|
17
|
+
h1 { font-size: 20px; margin: 0 0 4px; color: var(--accent); }
|
|
18
|
+
.muted { color: var(--muted); font-size: 13px; }
|
|
19
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 12px 0; }
|
|
20
|
+
button { background: var(--accent); color: #fff; border: 0; border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }
|
|
21
|
+
button.secondary { background: transparent; color: var(--fg); border: 1px solid var(--line); }
|
|
22
|
+
form { display: flex; gap: 8px; }
|
|
23
|
+
input { flex: 1; min-width: 0; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; font: inherit; }
|
|
24
|
+
#state { padding: 2px 8px; border-radius: 99px; border: 1px solid var(--accent); color: var(--accent); font-size: 13px; }
|
|
25
|
+
#log { border-top: 1px solid var(--line); margin-top: 12px; padding-top: 8px; }
|
|
26
|
+
#log p { margin: 6px 0; } .user { font-weight: 600; } .note { color: var(--muted); font-size: 13px; }
|
|
27
|
+
</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<main>
|
|
31
|
+
<h1>vRealTime</h1>
|
|
32
|
+
<div class="muted" id="status">connecting…</div>
|
|
33
|
+
<div class="row">
|
|
34
|
+
<button id="mic">Start mic</button>
|
|
35
|
+
<button id="stop" class="secondary">Interrupt</button>
|
|
36
|
+
<span id="state">idle</span>
|
|
37
|
+
</div>
|
|
38
|
+
<form id="form"><input id="text" placeholder="Type a message" autocomplete="off"><button>Send</button></form>
|
|
39
|
+
<div id="log"></div>
|
|
40
|
+
</main>
|
|
41
|
+
<script type="module">
|
|
42
|
+
const RATE = 24000, FRAME = 480, TRANSIENT = ['hello', 'ack', 'ping'];
|
|
43
|
+
const $ = (id) => document.getElementById(id);
|
|
44
|
+
let seq = 0, lastSeq = 0, epoch = 0, closedEpoch = -1, audioSeq = 0;
|
|
45
|
+
|
|
46
|
+
const tools = {
|
|
47
|
+
set_accent_color: {
|
|
48
|
+
description: 'Set the page accent color to any CSS color.',
|
|
49
|
+
parameters: { type: 'object', properties: { color: { type: 'string' } }, required: ['color'] },
|
|
50
|
+
run: ({ color }) => { document.documentElement.style.setProperty('--accent', color); return { color }; },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ws = new WebSocket(location.origin.replace(/^http/, 'ws') + location.pathname);
|
|
55
|
+
ws.binaryType = 'arraybuffer';
|
|
56
|
+
const send = (m) => { if (ws.readyState === 1) ws.send(JSON.stringify({ v: 1, seq: TRANSIENT.includes(m.type) ? 0 : ++seq, ...m })); };
|
|
57
|
+
ws.onopen = () => send({
|
|
58
|
+
type: 'hello',
|
|
59
|
+
tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, parameters: t.parameters })),
|
|
60
|
+
input: { encoding: 'pcm16', sampleRate: RATE }, output: { encoding: 'pcm16', sampleRate: RATE },
|
|
61
|
+
});
|
|
62
|
+
ws.onclose = (e) => { $('status').textContent = 'disconnected (' + e.code + ')'; };
|
|
63
|
+
setInterval(() => { send({ type: 'ping' }); if (lastSeq) send({ type: 'ack', upTo: lastSeq }); }, 10000);
|
|
64
|
+
|
|
65
|
+
const lines = new Map();
|
|
66
|
+
function line(id, role, text) {
|
|
67
|
+
let el = lines.get(id);
|
|
68
|
+
if (!el) { el = document.createElement('p'); el.className = role; $('log').append(el); lines.set(id, el); }
|
|
69
|
+
el.textContent = (role === 'user' ? 'You: ' : 'Agent: ') + text;
|
|
70
|
+
el.scrollIntoView({ block: 'end' });
|
|
71
|
+
}
|
|
72
|
+
function note(text) { const el = document.createElement('p'); el.className = 'note'; el.textContent = text; $('log').append(el); }
|
|
73
|
+
|
|
74
|
+
// Playback: schedule PCM16 chunks back to back; track what was actually heard per item.
|
|
75
|
+
let ctx, playhead = 0, sources = [], item;
|
|
76
|
+
const audio = () => (ctx ??= new AudioContext({ sampleRate: RATE }));
|
|
77
|
+
function play(bytes) {
|
|
78
|
+
const c = audio(), n = bytes.byteLength >> 1, view = new DataView(bytes.buffer, bytes.byteOffset, n * 2);
|
|
79
|
+
const buffer = c.createBuffer(1, n, RATE), data = buffer.getChannelData(0);
|
|
80
|
+
for (let i = 0; i < n; i++) data[i] = view.getInt16(i * 2, true) / 32768;
|
|
81
|
+
const src = c.createBufferSource();
|
|
82
|
+
src.buffer = buffer; src.connect(c.destination);
|
|
83
|
+
const at = Math.max(playhead, c.currentTime + 0.02);
|
|
84
|
+
if (item.start === undefined) item.start = at;
|
|
85
|
+
src.start(at); playhead = at + buffer.duration; item.end = playhead;
|
|
86
|
+
sources.push(src); src.onended = () => { sources = sources.filter((s) => s !== src); };
|
|
87
|
+
}
|
|
88
|
+
function stopPlayback() {
|
|
89
|
+
for (const s of sources) s.stop();
|
|
90
|
+
sources = []; playhead = 0;
|
|
91
|
+
if (item && item.start !== undefined) {
|
|
92
|
+
const heard = Math.max(0, Math.min(audio().currentTime, item.end) - item.start) * 1000;
|
|
93
|
+
send({ type: 'playback', itemId: item.id, playedMs: Math.round(heard) });
|
|
94
|
+
}
|
|
95
|
+
item = undefined;
|
|
96
|
+
}
|
|
97
|
+
function interrupt() {
|
|
98
|
+
closedEpoch = Math.max(closedEpoch, epoch);
|
|
99
|
+
stopPlayback();
|
|
100
|
+
send({ type: 'interrupt', epoch, reason: 'user' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function runTool(m) {
|
|
104
|
+
const tool = tools[m.name];
|
|
105
|
+
if (m.epoch <= closedEpoch) return send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: false, code: 'INTERRUPTED', message: 'interrupted' });
|
|
106
|
+
if (!tool) return send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: false, code: 'FAILED', message: 'unknown tool' });
|
|
107
|
+
try {
|
|
108
|
+
const output = await tool.run(m.arguments);
|
|
109
|
+
send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: true, output });
|
|
110
|
+
note('tool ' + m.name + ' ' + JSON.stringify(m.arguments));
|
|
111
|
+
} catch (e) {
|
|
112
|
+
send({ type: 'tool.result', callId: m.callId, epoch: m.epoch, ok: false, code: 'FAILED', message: String(e) });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ws.onmessage = (e) => {
|
|
117
|
+
if (typeof e.data !== 'string') {
|
|
118
|
+
const bytes = new Uint8Array(e.data);
|
|
119
|
+
if (bytes[0] === 2 && item) play(bytes.subarray(5));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const m = JSON.parse(e.data);
|
|
123
|
+
if (m.seq > 0) { if (m.seq <= lastSeq) return; lastSeq = m.seq; }
|
|
124
|
+
switch (m.type) {
|
|
125
|
+
case 'ready': $('status').textContent = 'session ' + m.sessionId; break;
|
|
126
|
+
case 'turn.state':
|
|
127
|
+
epoch = Math.max(epoch, m.epoch); $('state').textContent = m.state;
|
|
128
|
+
if (m.state === 'listening' && sources.length) stopPlayback();
|
|
129
|
+
break;
|
|
130
|
+
case 'transcript': line(m.itemId, m.role, m.text); break;
|
|
131
|
+
case 'text': line(m.responseId, 'agent', ((lines.get(m.responseId)?.textContent ?? 'Agent: ').slice(7)) + m.delta); break;
|
|
132
|
+
case 'audio.start': item = m.epoch > closedEpoch ? { id: m.itemId } : undefined; break;
|
|
133
|
+
case 'tool.call': runTool(m); break;
|
|
134
|
+
case 'interrupted': closedEpoch = Math.max(closedEpoch, m.epoch); stopPlayback(); note('interrupted'); break;
|
|
135
|
+
case 'error': note('error ' + m.code + ': ' + m.message); break;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
$('form').onsubmit = (e) => {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
const text = $('text').value.trim();
|
|
142
|
+
if (!text) return;
|
|
143
|
+
stopPlayback(); line('local-' + seq, 'user', text); send({ type: 'text', text }); $('text').value = '';
|
|
144
|
+
};
|
|
145
|
+
$('stop').onclick = interrupt;
|
|
146
|
+
|
|
147
|
+
// Microphone: AudioWorklet → 20 ms PCM16 frames → [kind=1][seq u32 BE][payload].
|
|
148
|
+
const WORKLET = \`class Capture extends AudioWorkletProcessor {
|
|
149
|
+
constructor() { super(); this.buf = new Int16Array(\${FRAME}); this.n = 0; }
|
|
150
|
+
process(inputs) {
|
|
151
|
+
const ch = inputs[0][0];
|
|
152
|
+
if (ch) for (let i = 0; i < ch.length; i++) {
|
|
153
|
+
const s = Math.max(-1, Math.min(1, ch[i]));
|
|
154
|
+
this.buf[this.n++] = s < 0 ? s * 32768 : s * 32767;
|
|
155
|
+
if (this.n === \${FRAME}) { this.port.postMessage(this.buf.buffer.slice(0)); this.n = 0; }
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
registerProcessor('vrt-capture', Capture);\`;
|
|
161
|
+
let mic;
|
|
162
|
+
$('mic').onclick = async () => {
|
|
163
|
+
if (mic) { mic.getTracks().forEach((t) => t.stop()); mic = undefined; $('mic').textContent = 'Start mic'; return; }
|
|
164
|
+
const c = audio();
|
|
165
|
+
await c.resume();
|
|
166
|
+
mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 } });
|
|
167
|
+
await c.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET], { type: 'text/javascript' })));
|
|
168
|
+
const node = new AudioWorkletNode(c, 'vrt-capture');
|
|
169
|
+
node.port.onmessage = ({ data }) => {
|
|
170
|
+
if (!mic || ws.readyState !== 1) return;
|
|
171
|
+
const frame = new Uint8Array(5 + data.byteLength);
|
|
172
|
+
frame[0] = 1; new DataView(frame.buffer).setUint32(1, audioSeq++ >>> 0); frame.set(new Uint8Array(data), 5);
|
|
173
|
+
ws.send(frame);
|
|
174
|
+
};
|
|
175
|
+
c.createMediaStreamSource(mic).connect(node);
|
|
176
|
+
$('mic').textContent = 'Stop mic';
|
|
177
|
+
};
|
|
178
|
+
</script>
|
|
179
|
+
</body>
|
|
180
|
+
</html>
|
|
181
|
+
`;
|