dsh-realtime-audio-ws 0.1.2 → 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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-realtime-audio-ws",
3
- "version": "0.1.2",
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/index.js"
15
+ "default": "./lib/client/bundle.js"
16
16
  },
17
17
  "./src/*": "./src/*",
18
18
  "./package.json": "./package.json"
@@ -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
  }