dsh-realtime-audio-ws 0.1.1 → 0.1.3
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/lib/client/bundle.js +278 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +16 -2
- package/lib/index.js.map +1 -1
- package/lib/types.d.ts +12 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +14 -1
- package/src/types.ts +13 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
|
|
2
|
+
factory: (require) => {
|
|
3
|
+
const exports = {}
|
|
4
|
+
const module = { exports }
|
|
5
|
+
"use strict";
|
|
6
|
+
/**
|
|
7
|
+
* The browser face: microphone in, speaker out, over the host route.
|
|
8
|
+
*
|
|
9
|
+
* This is the client half of the transport. `dsh-realtime-audio-ws` claims the socket on the host and
|
|
10
|
+
* bridges it to the agent's audio events; this file opens that socket, captures the microphone into it, and
|
|
11
|
+
* plays what comes back.
|
|
12
|
+
*
|
|
13
|
+
* **One file, on purpose.** A client face is served as a single resource (`<package>/client.js`), so a
|
|
14
|
+
* relative value import would emit a second file the loader never serves. Everything therefore lives here,
|
|
15
|
+
* including the API shapes it needs.
|
|
16
|
+
*
|
|
17
|
+
* **No DOM lib, also on purpose.** The browser APIs are described structurally and read off `globalThis`
|
|
18
|
+
* through one cast, so this compiles in the package's ordinary program — no second tsconfig, no DOM lib,
|
|
19
|
+
* no bundler — and every path is testable by injecting fakes. `standard-browser-types` would have bought
|
|
20
|
+
* accurate types for six objects at the cost of a whole second build face; this is the cheaper trade.
|
|
21
|
+
*
|
|
22
|
+
* **`ScriptProcessorNode`, and why.** The modern capture path is an `AudioWorklet`, whose module must be
|
|
23
|
+
* fetched from a URL — a `blob:` URL, which a page's Content-Security-Policy can refuse. The desktop app
|
|
24
|
+
* demonstrably *has* a CSP (a `connect-src 'none'` string is in its bundle, governing the shell pages), and
|
|
25
|
+
* I have not read the app page's policy. `ScriptProcessorNode` needs no URL, no module fetch and no policy
|
|
26
|
+
* grant, and it runs in the page, so the whole capture path is testable. It is deprecated and its latency is
|
|
27
|
+
* worse. That is a deliberate, disclosed trade: swap it for a worklet once the app page's `script-src` has
|
|
28
|
+
* actually been read, rather than shipping a capture path that a policy might silently kill.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
|
|
32
|
+
exports.socketUrl = socketUrl;
|
|
33
|
+
exports.pcm16FromFloat32 = pcm16FromFloat32;
|
|
34
|
+
exports.float32FromPcm16 = float32FromPcm16;
|
|
35
|
+
exports.defaultDeps = defaultDeps;
|
|
36
|
+
exports.createAudioClient = createAudioClient;
|
|
37
|
+
exports.apply = apply;
|
|
38
|
+
/** Browser-side plugin name. */
|
|
39
|
+
exports.name = 'realtime-audio-client';
|
|
40
|
+
/**
|
|
41
|
+
* No client services. The socket, the device and the audio graph are all this plugin's own, so it injects
|
|
42
|
+
* nothing — and a client face that needs nothing cannot be broken by another plugin's absence.
|
|
43
|
+
*/
|
|
44
|
+
exports.inject = [];
|
|
45
|
+
/** Where the host route lives. Duplicated from the host package because a client face cannot import it. */
|
|
46
|
+
exports.DEFAULT_PATH = '/dsh-realtime/audio';
|
|
47
|
+
/** The session's declared rate. The graph is opened at this rate or the client refuses to run. */
|
|
48
|
+
exports.SAMPLE_RATE = 24_000;
|
|
49
|
+
/** Capture chunk in samples. 1024 at 24 kHz is ~43 ms: the 2^10 floor the API allows, so latency is lowest. */
|
|
50
|
+
exports.CAPTURE_BUFFER = 1024;
|
|
51
|
+
/** Backlog past which incoming speech is dropped rather than queued — the same rule the host carries. */
|
|
52
|
+
exports.MAX_BACKLOG_SECONDS = 0.5;
|
|
53
|
+
/** Global the client publishes itself on. There is no UI surface yet; this is how it is started. */
|
|
54
|
+
exports.GLOBAL_KEY = '__dshRealtimeAudio';
|
|
55
|
+
/** Optional host-injected settings. A future index-injection row may set the path; the default matches. */
|
|
56
|
+
exports.INJECTED_KEY = '__DSH_REALTIME_AUDIO__';
|
|
57
|
+
// ---- pure helpers --------------------------------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* The socket URL for the host route.
|
|
60
|
+
*
|
|
61
|
+
* @param location - the page's location, or undefined when there is none.
|
|
62
|
+
* @param path - the route pathname.
|
|
63
|
+
* @returns the URL, or undefined when this page cannot host a socket at all.
|
|
64
|
+
*/
|
|
65
|
+
function socketUrl(location, path) {
|
|
66
|
+
if (location === undefined || location.host === '')
|
|
67
|
+
return undefined;
|
|
68
|
+
// An https page may not open a ws:// socket — the browser blocks it as mixed content — so the scheme has
|
|
69
|
+
// to follow the page rather than being decided here.
|
|
70
|
+
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
71
|
+
return `${scheme}://${location.host}${path}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Convert one captured float block to the wire's PCM16.
|
|
75
|
+
*
|
|
76
|
+
* Clamped, not wrapped: a sample above 1.0 is a loud peak, and letting it wrap turns a loud voice into
|
|
77
|
+
* noise. Asymmetric on purpose — 32767 is the largest Int16 and -32768 the smallest, so scaling both by the
|
|
78
|
+
* same factor would clip every negative peak one step early.
|
|
79
|
+
*
|
|
80
|
+
* @param input - samples in the range [-1, 1].
|
|
81
|
+
* @returns the same samples as little-endian PCM16 bytes.
|
|
82
|
+
*/
|
|
83
|
+
function pcm16FromFloat32(input) {
|
|
84
|
+
const out = new Int16Array(input.length);
|
|
85
|
+
// `entries()` rather than an index: under `noUncheckedIndexedAccess` an indexed read is
|
|
86
|
+
// `number | undefined`, and guarding that would add a branch that can never be taken at runtime.
|
|
87
|
+
for (const [index, raw] of input.entries()) {
|
|
88
|
+
const sample = Math.max(-1, Math.min(1, raw));
|
|
89
|
+
out[index] = Math.round(sample < 0 ? sample * 32768 : sample * 32767);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read one PCM16 frame as playback samples.
|
|
95
|
+
*
|
|
96
|
+
* @param bytes - the frame.
|
|
97
|
+
* @returns samples in [-1, 1).
|
|
98
|
+
*/
|
|
99
|
+
function float32FromPcm16(bytes) {
|
|
100
|
+
const samples = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
|
|
101
|
+
const out = new Float32Array(samples.length);
|
|
102
|
+
for (const [index, sample] of samples.entries())
|
|
103
|
+
out[index] = sample / 32768;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Read the real browser APIs, and any settings the host injected.
|
|
108
|
+
*
|
|
109
|
+
* @param scope - the global scope, injectable so both the present and absent cases are testable without
|
|
110
|
+
* mutating the process's own globals.
|
|
111
|
+
* @returns the production dependencies. Every one may be absent, which is why `start` reports rather than
|
|
112
|
+
* throws: a page without a microphone API is a fact about the page, not a defect in the caller.
|
|
113
|
+
*/
|
|
114
|
+
function defaultDeps(scope = globalThis) {
|
|
115
|
+
const media = scope.navigator?.mediaDevices;
|
|
116
|
+
const Audio = scope.AudioContext;
|
|
117
|
+
const Socket = scope.WebSocket;
|
|
118
|
+
return {
|
|
119
|
+
location: scope.location,
|
|
120
|
+
getUserMedia: media === undefined ? undefined : (constraints) => media.getUserMedia(constraints),
|
|
121
|
+
createAudioContext: Audio === undefined ? undefined : (rate) => new Audio({ sampleRate: rate }),
|
|
122
|
+
createSocket: Socket === undefined ? undefined : (url) => new Socket(url),
|
|
123
|
+
injected: scope.__DSH_REALTIME_AUDIO__,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The client: one microphone in, one socket out, one speaker fed.
|
|
128
|
+
*
|
|
129
|
+
* @param deps - the outside world, injected so every path is testable.
|
|
130
|
+
* @returns the start/stop/state handle.
|
|
131
|
+
*/
|
|
132
|
+
function createAudioClient(deps) {
|
|
133
|
+
let current = { kind: 'idle' };
|
|
134
|
+
let socket;
|
|
135
|
+
let context;
|
|
136
|
+
let stream;
|
|
137
|
+
let processor;
|
|
138
|
+
let silence;
|
|
139
|
+
let playsAt = 0;
|
|
140
|
+
const fail = (reason) => {
|
|
141
|
+
current = { kind: 'failed', reason };
|
|
142
|
+
return current;
|
|
143
|
+
};
|
|
144
|
+
/** Release everything held. Idempotent, because stop, an error and a close all reach it. */
|
|
145
|
+
const release = () => {
|
|
146
|
+
socket?.close();
|
|
147
|
+
socket = undefined;
|
|
148
|
+
processor?.disconnect();
|
|
149
|
+
processor = undefined;
|
|
150
|
+
silence?.disconnect();
|
|
151
|
+
silence = undefined;
|
|
152
|
+
for (const track of stream?.getTracks() ?? [])
|
|
153
|
+
track.stop();
|
|
154
|
+
stream = undefined;
|
|
155
|
+
const closing = context;
|
|
156
|
+
context = undefined;
|
|
157
|
+
if (closing !== undefined)
|
|
158
|
+
void closing.close().catch(() => undefined);
|
|
159
|
+
playsAt = 0;
|
|
160
|
+
};
|
|
161
|
+
/** Play one frame, dropping it rather than queueing when we are already behind. */
|
|
162
|
+
const play = (bytes) => {
|
|
163
|
+
if (context === undefined)
|
|
164
|
+
return;
|
|
165
|
+
const samples = float32FromPcm16(bytes);
|
|
166
|
+
if (samples.length === 0)
|
|
167
|
+
return;
|
|
168
|
+
const buffer = context.createBuffer(1, samples.length, exports.SAMPLE_RATE);
|
|
169
|
+
buffer.getChannelData(0).set(samples);
|
|
170
|
+
const now = context.currentTime;
|
|
171
|
+
const at = Math.max(now, playsAt);
|
|
172
|
+
// Unbuffered, like the host: audio that is already late is dropped rather than played behind, because a
|
|
173
|
+
// queue that grows converts a stutter into a permanent delay that never recovers.
|
|
174
|
+
if (at - now > exports.MAX_BACKLOG_SECONDS)
|
|
175
|
+
return;
|
|
176
|
+
const source = context.createBufferSource();
|
|
177
|
+
source.buffer = buffer;
|
|
178
|
+
source.connect(context.destination);
|
|
179
|
+
source.start(at);
|
|
180
|
+
playsAt = at + buffer.duration;
|
|
181
|
+
};
|
|
182
|
+
/** Build the capture graph and start shipping frames. */
|
|
183
|
+
const attachCapture = () => {
|
|
184
|
+
if (context === undefined || stream === undefined || socket === undefined) {
|
|
185
|
+
return fail('the audio graph went away before the socket opened');
|
|
186
|
+
}
|
|
187
|
+
const source = context.createMediaStreamSource(stream);
|
|
188
|
+
const node = context.createScriptProcessor(exports.CAPTURE_BUFFER, 1, 1);
|
|
189
|
+
node.onaudioprocess = (event) => {
|
|
190
|
+
const pcm16 = pcm16FromFloat32(event.inputBuffer.getChannelData(0));
|
|
191
|
+
socket?.send(new Uint8Array(pcm16.buffer));
|
|
192
|
+
};
|
|
193
|
+
// A ScriptProcessorNode only fires while connected to the destination — and connecting the microphone
|
|
194
|
+
// there is a feedback loop. A zero-gain node keeps it running with nothing audible, which is the whole
|
|
195
|
+
// reason this chain looks the way it does.
|
|
196
|
+
const mute = context.createGain();
|
|
197
|
+
mute.gain.value = 0;
|
|
198
|
+
source.connect(node);
|
|
199
|
+
node.connect(mute);
|
|
200
|
+
mute.connect(context.destination);
|
|
201
|
+
processor = node;
|
|
202
|
+
silence = mute;
|
|
203
|
+
current = { kind: 'live' };
|
|
204
|
+
return current;
|
|
205
|
+
};
|
|
206
|
+
const stop = () => {
|
|
207
|
+
release();
|
|
208
|
+
current = { kind: 'idle' };
|
|
209
|
+
};
|
|
210
|
+
const start = async () => {
|
|
211
|
+
if (current.kind === 'live')
|
|
212
|
+
return current;
|
|
213
|
+
const url = socketUrl(deps.location, deps.injected?.path ?? exports.DEFAULT_PATH);
|
|
214
|
+
if (url === undefined)
|
|
215
|
+
return fail('this page has no location to open a socket against');
|
|
216
|
+
if (deps.getUserMedia === undefined)
|
|
217
|
+
return fail('this page has no microphone API');
|
|
218
|
+
if (deps.createAudioContext === undefined)
|
|
219
|
+
return fail('this page has no audio API');
|
|
220
|
+
if (deps.createSocket === undefined)
|
|
221
|
+
return fail('this page has no WebSocket API');
|
|
222
|
+
try {
|
|
223
|
+
stream = await deps.getUserMedia({
|
|
224
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// A refused permission is the ordinary case here, not an error to hide: the reason is what tells the
|
|
229
|
+
// user whether to grant it or to look somewhere else.
|
|
230
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
231
|
+
}
|
|
232
|
+
context = deps.createAudioContext(exports.SAMPLE_RATE);
|
|
233
|
+
if (context.sampleRate !== exports.SAMPLE_RATE) {
|
|
234
|
+
// Refuse rather than mislabel. Streaming 48 kHz samples declared as 24 kHz arrives at half speed and
|
|
235
|
+
// sounds like a provider fault, which is the most expensive possible way to learn about a resampler.
|
|
236
|
+
const opened = context.sampleRate;
|
|
237
|
+
release();
|
|
238
|
+
return fail(`the audio graph opened at ${String(opened)} Hz, not ${String(exports.SAMPLE_RATE)}`);
|
|
239
|
+
}
|
|
240
|
+
const opened = deps.createSocket(url);
|
|
241
|
+
socket = opened;
|
|
242
|
+
opened.binaryType = 'arraybuffer';
|
|
243
|
+
opened.onmessage = (event) => {
|
|
244
|
+
if (event.data instanceof ArrayBuffer)
|
|
245
|
+
play(new Uint8Array(event.data));
|
|
246
|
+
};
|
|
247
|
+
opened.onclose = () => {
|
|
248
|
+
release();
|
|
249
|
+
current = { kind: 'idle' };
|
|
250
|
+
};
|
|
251
|
+
return await new Promise((resolve) => {
|
|
252
|
+
opened.onopen = () => { resolve(attachCapture()); };
|
|
253
|
+
opened.onerror = () => { resolve(fail('the host refused the audio socket')); };
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
return { start, stop, state: () => current };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The client plugin. Publishes the client on a global and releases it with the plugin.
|
|
260
|
+
*
|
|
261
|
+
* There is no UI surface yet, so a global is how it is driven — a settings card is its own increment and a
|
|
262
|
+
* bigger one than this. `inject` is empty for the same reason: nothing here needs another plugin.
|
|
263
|
+
*
|
|
264
|
+
* @param ctx - the client context, used only to own the lifetime.
|
|
265
|
+
*/
|
|
266
|
+
function apply(ctx) {
|
|
267
|
+
const client = createAudioClient(defaultDeps());
|
|
268
|
+
globalThis[exports.GLOBAL_KEY] = {
|
|
269
|
+
start: () => client.start(),
|
|
270
|
+
stop: () => { client.stop(); },
|
|
271
|
+
state: () => client.state(),
|
|
272
|
+
};
|
|
273
|
+
// A held microphone and a live audio graph must not outlive the plugin that opened them.
|
|
274
|
+
ctx.effect(() => () => { client.stop(); }, 'realtime-audio-client');
|
|
275
|
+
}
|
|
276
|
+
return exports
|
|
277
|
+
},
|
|
278
|
+
})
|
package/lib/index.d.ts
CHANGED
|
@@ -22,10 +22,12 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
|
22
22
|
path: Schema<string, string, "defined">;
|
|
23
23
|
maxFrameBytes: Schema<number, number, "defined">;
|
|
24
24
|
maxConnections: Schema<number, number, "defined">;
|
|
25
|
+
openSessionOnConnect: Schema<boolean, boolean, "defined">;
|
|
25
26
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
26
27
|
path: Schema<string, string, "defined">;
|
|
27
28
|
maxFrameBytes: Schema<number, number, "defined">;
|
|
28
29
|
maxConnections: Schema<number, number, "defined">;
|
|
30
|
+
openSessionOnConnect: Schema<boolean, boolean, "defined">;
|
|
29
31
|
}>>, "plain">;
|
|
30
32
|
export declare function apply(ctx: Context, config: RealtimeAudioWsConfig): void;
|
|
31
33
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAOlD,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAOlD,OAAO,EAML,KAAK,qBAAqB,EAC3B,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAEzF,mBAAmB;AACnB,eAAO,MAAM,IAAI,sBAAsB,CAAA;AAEvC,eAAO,MAAM,MAAM;;;;;;;;;;aAQjB,CAAA;AAqBF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,GAAG,IAAI,CAoDvE"}
|
package/lib/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import Schema from '@deepseek-ai/schemastery';
|
|
14
14
|
import { attachAudioSocket } from './bridge.js';
|
|
15
15
|
import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
|
|
16
|
-
import { DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_FRAME_BYTES, DEFAULT_PATH, } from './types.js';
|
|
16
|
+
import { DEFAULT_MAX_CONNECTIONS, DEFAULT_MAX_FRAME_BYTES, DEFAULT_OPEN_SESSION_ON_CONNECT, DEFAULT_PATH, } from './types.js';
|
|
17
17
|
export * from './types.js';
|
|
18
18
|
export { attachAudioSocket, toBytes } from './bridge.js';
|
|
19
19
|
export { createUpgradeAcceptor, rejectUpgrade } from './upgrade.js';
|
|
@@ -23,6 +23,10 @@ export const Config = Schema.object({
|
|
|
23
23
|
path: Schema.string().default(DEFAULT_PATH),
|
|
24
24
|
maxFrameBytes: Schema.natural().default(DEFAULT_MAX_FRAME_BYTES),
|
|
25
25
|
maxConnections: Schema.natural().default(DEFAULT_MAX_CONNECTIONS),
|
|
26
|
+
// True, and deliberately not the same decision as the agent's `autoStart`. That one opens a session at
|
|
27
|
+
// boot with nobody asking, which is why it is false. This one opens a session because someone connected a
|
|
28
|
+
// microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
|
|
29
|
+
openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
|
|
26
30
|
});
|
|
27
31
|
export function apply(ctx, config) {
|
|
28
32
|
ctx.inject(['connection', 'webServer'], (injected) => {
|
|
@@ -50,11 +54,21 @@ export function apply(ctx, config) {
|
|
|
50
54
|
return;
|
|
51
55
|
}
|
|
52
56
|
clients.add(client);
|
|
57
|
+
// Ask for the session before the bridge goes in, so the first frames are written into a session
|
|
58
|
+
// that is being opened rather than dropped by the mic seam's no-session rule.
|
|
59
|
+
if (config.openSessionOnConnect)
|
|
60
|
+
ctx.emit('realtime-agent/start');
|
|
53
61
|
attachAudioSocket(client, {
|
|
54
62
|
emitMic: (pcm16) => { ctx.emit('realtime-agent/mic', pcm16); },
|
|
55
63
|
subscribeAudio: (listener) => ctx.on('realtime-agent/audio', listener),
|
|
56
64
|
maxFrameBytes: config.maxFrameBytes,
|
|
57
|
-
onDetach: () => {
|
|
65
|
+
onDetach: () => {
|
|
66
|
+
clients.delete(client);
|
|
67
|
+
// The last client leaving ends the session. This also covers the transport's own disposal,
|
|
68
|
+
// which terminates its clients — so a profile reload does not leave a session nobody holds.
|
|
69
|
+
if (config.openSessionOnConnect && clients.size === 0)
|
|
70
|
+
ctx.emit('realtime-agent/stop');
|
|
71
|
+
},
|
|
58
72
|
});
|
|
59
73
|
});
|
|
60
74
|
},
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAM7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAC/C,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AACnE,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,YAAY,GAGb,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAA8B,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAwB,MAAM,cAAc,CAAA;AAEzF,mBAAmB;AACnB,MAAM,CAAC,MAAM,IAAI,GAAG,mBAAmB,CAAA;AAEvC,MAAM,CAAC,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IAC3C,aAAa,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IAChE,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAM7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAC/C,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AACnE,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,+BAA+B,EAC/B,YAAY,GAGb,MAAM,YAAY,CAAA;AAEnB,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAA8B,MAAM,aAAa,CAAA;AACpF,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAwB,MAAM,cAAc,CAAA;AAEzF,mBAAmB;AACnB,MAAM,CAAC,MAAM,IAAI,GAAG,mBAAmB,CAAA;AAEvC,MAAM,CAAC,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IAC3C,aAAa,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IAChE,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IACjE,uGAAuG;IACvG,0GAA0G;IAC1G,uGAAuG;IACvG,oBAAoB,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,+BAA+B,CAAC;CAChF,CAAC,CAAA;AAqBF,MAAM,UAAU,KAAK,CAAC,GAAY,EAAE,MAA6B;IAC/D,GAAG,CAAC,MAAM,CAAC,CAAC,YAAY,EAAE,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,EAAE;QACnD,MAAM,QAAQ,GAAG,QAA+E,CAAA;QAChG,MAAM,QAAQ,GAAG,qBAAqB,CAAC,MAAM,CAAC,aAAa,CAAC,CAAA;QAC5D,MAAM,OAAO,GAAG,IAAI,GAAG,EAAe,CAAA;QAEtC,GAAG,CAAC,MAAM,CAAC,GAAG,EAAE;YACd,MAAM,UAAU,GAAG,QAAQ,CAAC,SAAS,CAAC,eAAe,CAAC;gBACpD,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;oBAC7B,iGAAiG;oBACjG,4FAA4F;oBAC5F,2FAA2F;oBAC3F,iGAAiG;oBACjG,0CAA0C;oBAC1C,MAAM,SAAS,GAAG,QAAQ,CAAC,UAAU,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAA;oBAC3D,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;wBAC5B,aAAa,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;wBAChC,OAAM;oBACR,CAAC;oBACD,QAAQ,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,EAAE;wBACnD,IAAI,OAAO,CAAC,IAAI,IAAI,MAAM,CAAC,cAAc,EAAE,CAAC;4BAC1C,oFAAoF;4BACpF,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;4BAC1B,OAAM;wBACR,CAAC;wBACD,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;wBACnB,gGAAgG;wBAChG,8EAA8E;wBAC9E,IAAI,MAAM,CAAC,oBAAoB;4BAAE,GAAG,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAA;wBACjE,iBAAiB,CAAC,MAAM,EAAE;4BACxB,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC,oBAAoB,EAAE,KAAK,CAAC,CAAA,CAAC,CAAC;4BAC7D,cAAc,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,sBAAsB,EAAE,QAAQ,CAAC;4BACtE,aAAa,EAAE,MAAM,CAAC,aAAa;4BACnC,QAAQ,EAAE,GAAG,EAAE;gCACb,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;gCACtB,2FAA2F;gCAC3F,4FAA4F;gCAC5F,IAAI,MAAM,CAAC,oBAAoB,IAAI,OAAO,CAAC,IAAI,KAAK,CAAC;oCAAE,GAAG,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAA;4BACxF,CAAC;yBACF,CAAC,CAAA;oBACJ,CAAC,CAAC,CAAA;gBACJ,CAAC;aACF,CAAC,CAAA;YACF,OAAO,KAAK,IAAI,EAAE;gBAChB,UAAU,EAAE,CAAA;gBACZ,KAAK,MAAM,MAAM,IAAI,OAAO;oBAAE,MAAM,CAAC,SAAS,EAAE,CAAA;gBAChD,OAAO,CAAC,KAAK,EAAE,CAAA;gBACf,MAAM,QAAQ,CAAC,KAAK,EAAE,CAAA;YACxB,CAAC,CAAA;QACH,CAAC,EAAE,sBAAsB,MAAM,CAAC,IAAI,EAAE,CAAC,CAAA;IACzC,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
package/lib/types.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare const DEFAULT_PATH = "/dsh-realtime/audio";
|
|
|
17
17
|
export declare const DEFAULT_MAX_FRAME_BYTES = 480000;
|
|
18
18
|
/** How many sockets may be attached at once. One microphone is the honest default. */
|
|
19
19
|
export declare const DEFAULT_MAX_CONNECTIONS = 1;
|
|
20
|
+
/** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
|
|
21
|
+
export declare const DEFAULT_OPEN_SESSION_ON_CONNECT = true;
|
|
20
22
|
export interface RealtimeAudioWsConfig {
|
|
21
23
|
/**
|
|
22
24
|
* Absolute pathname to claim. Registered exactly, so it must be distinct from every other route in the
|
|
@@ -33,6 +35,16 @@ export interface RealtimeAudioWsConfig {
|
|
|
33
35
|
* two microphones on one session is a fault, not a feature.
|
|
34
36
|
*/
|
|
35
37
|
readonly maxConnections: number;
|
|
38
|
+
/**
|
|
39
|
+
* Ask the agent to open the voice session when an authenticated client connects, and to close it when the
|
|
40
|
+
* last one leaves. Default true.
|
|
41
|
+
*
|
|
42
|
+
* This is not the agent's `autoStart`, and the two defaults differ for a reason. `autoStart` opens a
|
|
43
|
+
* session at boot with nobody asking, which is why it ships false. This one responds to a connection,
|
|
44
|
+
* which takes an explicit authenticated action — and with it false, a working microphone produces silence
|
|
45
|
+
* that looks like a fault anywhere but in this file.
|
|
46
|
+
*/
|
|
47
|
+
readonly openSessionOnConnect: boolean;
|
|
36
48
|
}
|
|
37
49
|
/**
|
|
38
50
|
* The slice of a WebSocket this package uses, described structurally.
|
package/lib/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,eAAO,MAAM,YAAY,wBAAwB,CAAA;AAEjD,6FAA6F;AAC7F,eAAO,MAAM,uBAAuB,SAAU,CAAA;AAE9C,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,0GAA0G;AAC1G,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,oBAAoB,EAAE,OAAO,CAAA;CACvC;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C,SAAS,IAAI,IAAI,CAAA;IACjB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAA;IACnF,EAAE,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;CAC9E"}
|
package/lib/types.js
CHANGED
|
@@ -17,4 +17,6 @@ export const DEFAULT_PATH = '/dsh-realtime/audio';
|
|
|
17
17
|
export const DEFAULT_MAX_FRAME_BYTES = 480_000;
|
|
18
18
|
/** How many sockets may be attached at once. One microphone is the honest default. */
|
|
19
19
|
export const DEFAULT_MAX_CONNECTIONS = 1;
|
|
20
|
+
/** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
|
|
21
|
+
export const DEFAULT_OPEN_SESSION_ON_CONNECT = true;
|
|
20
22
|
//# sourceMappingURL=types.js.map
|
package/lib/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,YAAY,GAAG,qBAAqB,CAAA;AAEjD,6FAA6F;AAC7F,MAAM,CAAC,MAAM,uBAAuB,GAAG,OAAO,CAAA;AAE9C,sFAAsF;AACtF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAA"}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,0GAA0G;AAC1G,MAAM,CAAC,MAAM,YAAY,GAAG,qBAAqB,CAAA;AAEjD,6FAA6F;AAC7F,MAAM,CAAC,MAAM,uBAAuB,GAAG,OAAO,CAAA;AAE9C,sFAAsF;AACtF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAA;AAExC,0GAA0G;AAC1G,MAAM,CAAC,MAAM,+BAA+B,GAAG,IAAI,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-realtime-audio-ws",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The host end of the client half's transport: a WebSocket upgrade route that bridges microphone audio in and agent speech out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"./client": {
|
|
14
14
|
"types": "./lib/client/index.d.ts",
|
|
15
|
-
"default": "./lib/client/
|
|
15
|
+
"default": "./lib/client/bundle.js"
|
|
16
16
|
},
|
|
17
17
|
"./src/*": "./src/*",
|
|
18
18
|
"./package.json": "./package.json"
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"ws": "^8.22.0",
|
|
55
|
-
"dsh-realtime-agent": "^0.2.
|
|
55
|
+
"dsh-realtime-agent": "^0.2.2"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@deepseek-ai/cordis": "^4.0.3",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/ws": "^8.18.2"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
|
-
"build": "tsc -b && tsc -p tsconfig.client.json",
|
|
67
|
+
"build": "tsc -b && tsc -p tsconfig.client.json && node ../../scripts/client-bundle.mjs .",
|
|
68
68
|
"typecheck": "tsc -b --dry"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createUpgradeAcceptor, rejectUpgrade } from './upgrade.ts'
|
|
|
22
22
|
import {
|
|
23
23
|
DEFAULT_MAX_CONNECTIONS,
|
|
24
24
|
DEFAULT_MAX_FRAME_BYTES,
|
|
25
|
+
DEFAULT_OPEN_SESSION_ON_CONNECT,
|
|
25
26
|
DEFAULT_PATH,
|
|
26
27
|
type AudioSocket,
|
|
27
28
|
type RealtimeAudioWsConfig,
|
|
@@ -38,6 +39,10 @@ export const Config = Schema.object({
|
|
|
38
39
|
path: Schema.string().default(DEFAULT_PATH),
|
|
39
40
|
maxFrameBytes: Schema.natural().default(DEFAULT_MAX_FRAME_BYTES),
|
|
40
41
|
maxConnections: Schema.natural().default(DEFAULT_MAX_CONNECTIONS),
|
|
42
|
+
// True, and deliberately not the same decision as the agent's `autoStart`. That one opens a session at
|
|
43
|
+
// boot with nobody asking, which is why it is false. This one opens a session because someone connected a
|
|
44
|
+
// microphone: a connection takes an explicit action, an authenticated one, and its absence is silence.
|
|
45
|
+
openSessionOnConnect: Schema.boolean().default(DEFAULT_OPEN_SESSION_ON_CONNECT),
|
|
41
46
|
})
|
|
42
47
|
|
|
43
48
|
/**
|
|
@@ -86,11 +91,19 @@ export function apply(ctx: Context, config: RealtimeAudioWsConfig): void {
|
|
|
86
91
|
return
|
|
87
92
|
}
|
|
88
93
|
clients.add(client)
|
|
94
|
+
// Ask for the session before the bridge goes in, so the first frames are written into a session
|
|
95
|
+
// that is being opened rather than dropped by the mic seam's no-session rule.
|
|
96
|
+
if (config.openSessionOnConnect) ctx.emit('realtime-agent/start')
|
|
89
97
|
attachAudioSocket(client, {
|
|
90
98
|
emitMic: (pcm16) => { ctx.emit('realtime-agent/mic', pcm16) },
|
|
91
99
|
subscribeAudio: (listener) => ctx.on('realtime-agent/audio', listener),
|
|
92
100
|
maxFrameBytes: config.maxFrameBytes,
|
|
93
|
-
onDetach: () => {
|
|
101
|
+
onDetach: () => {
|
|
102
|
+
clients.delete(client)
|
|
103
|
+
// The last client leaving ends the session. This also covers the transport's own disposal,
|
|
104
|
+
// which terminates its clients — so a profile reload does not leave a session nobody holds.
|
|
105
|
+
if (config.openSessionOnConnect && clients.size === 0) ctx.emit('realtime-agent/stop')
|
|
106
|
+
},
|
|
94
107
|
})
|
|
95
108
|
})
|
|
96
109
|
},
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,9 @@ export const DEFAULT_MAX_FRAME_BYTES = 480_000
|
|
|
21
21
|
/** How many sockets may be attached at once. One microphone is the honest default. */
|
|
22
22
|
export const DEFAULT_MAX_CONNECTIONS = 1
|
|
23
23
|
|
|
24
|
+
/** Whether an authenticated connection asks the agent to open the voice session. See the config field. */
|
|
25
|
+
export const DEFAULT_OPEN_SESSION_ON_CONNECT = true
|
|
26
|
+
|
|
24
27
|
export interface RealtimeAudioWsConfig {
|
|
25
28
|
/**
|
|
26
29
|
* Absolute pathname to claim. Registered exactly, so it must be distinct from every other route in the
|
|
@@ -37,6 +40,16 @@ export interface RealtimeAudioWsConfig {
|
|
|
37
40
|
* two microphones on one session is a fault, not a feature.
|
|
38
41
|
*/
|
|
39
42
|
readonly maxConnections: number
|
|
43
|
+
/**
|
|
44
|
+
* Ask the agent to open the voice session when an authenticated client connects, and to close it when the
|
|
45
|
+
* last one leaves. Default true.
|
|
46
|
+
*
|
|
47
|
+
* This is not the agent's `autoStart`, and the two defaults differ for a reason. `autoStart` opens a
|
|
48
|
+
* session at boot with nobody asking, which is why it ships false. This one responds to a connection,
|
|
49
|
+
* which takes an explicit authenticated action — and with it false, a working microphone produces silence
|
|
50
|
+
* that looks like a fault anywhere but in this file.
|
|
51
|
+
*/
|
|
52
|
+
readonly openSessionOnConnect: boolean
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
/**
|