@lumavox/client 0.7.0 → 0.10.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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The microphone, as the same raw PCM this socket already plays.
3
+ *
4
+ * The mirror of `player.ts`: 16-bit signed little-endian mono, at 24 kHz —
5
+ * the rate the socket's audio defaults to (`DEFAULT_SAMPLE_RATE`), and one
6
+ * of `RECORDING_RATES`.
7
+ *
8
+ * **No `MediaRecorder`, and that is the whole design.** It produces a
9
+ * *container* — webm/opus, whatever the browser feels like — which the socket
10
+ * cannot take: binary frames there are bare samples with no header, and the
11
+ * server would have to decode a container to find out what it was given. Web
12
+ * Audio hands over float samples instead, and converting those to 16-bit
13
+ * integers is four lines.
14
+ *
15
+ * Three things make it work in practice:
16
+ *
17
+ * - **This context is opened at 24 kHz** rather than resampled after the
18
+ * fact. Browsers accept a `sampleRate` on the constructor and do the
19
+ * conversion in the audio thread, which is both better and free. (The
20
+ * player's context is the device's own: `player.ts` says why.)
21
+ * - **`AudioWorklet` with a `ScriptProcessor` fallback.** The worklet runs off
22
+ * the main thread, so a busy React render cannot drop samples — which is
23
+ * heard as a clipped word rather than as a stutter, and is exactly the kind
24
+ * of fault that would be blamed on the recogniser.
25
+ * - **The track is stopped on `stop()`**, not merely disconnected. A live
26
+ * `MediaStreamTrack` leaves the browser's recording indicator on, and a
27
+ * microphone that looks like it is still listening is a trust problem
28
+ * whatever the code is doing.
29
+ *
30
+ * Loaded on the first press (`web-audio.ts`), so a page that never records
31
+ * never parses it.
32
+ */
33
+ /** The rate this records at, whatever is asked: the socket's default. */
34
+ export const RECORD_SAMPLE_RATE = 24000;
35
+ /** Multiplier from the -1..1 floats Web Audio gives to 16-bit signed. 32767,
36
+ * not 32768, so a full-scale sample cannot wrap to the opposite sign. */
37
+ const FULL_SCALE = 32767;
38
+ /** Samples per delivered chunk — about 85 ms at 24 kHz. Small enough that the
39
+ * upload overlaps the talking, large enough not to be one WebSocket frame per
40
+ * animation tick.
41
+ *
42
+ * **Applied to the worklet path too.** The worklet posts every 128-sample
43
+ * render quantum; one WebSocket frame a post is about 188 frames a second of
44
+ * 256 bytes, each a TLS record and a message the end user DO wakes for —
45
+ * sixteen times as many as at this size. Batched here, on the main thread,
46
+ * where the tail can be flushed synchronously when the button is released. */
47
+ export const FRAME_SAMPLES = 2048;
48
+ const WORKLET = `
49
+ class Tap extends AudioWorkletProcessor {
50
+ process(inputs) {
51
+ const channel = inputs[0]?.[0];
52
+ // A disconnected or silent-by-design input yields an empty array rather
53
+ // than zeroes; forwarding that would post empty frames forever.
54
+ if (channel && channel.length) this.port.postMessage(channel.slice());
55
+ return true;
56
+ }
57
+ }
58
+ registerProcessor("pcm-tap", Tap);
59
+ `;
60
+ /** Float samples to 16-bit signed little-endian, which is what the wire wants. */
61
+ export function toPcm16(samples) {
62
+ const out = new DataView(new ArrayBuffer(samples.length * 2));
63
+ for (let i = 0; i < samples.length; i++) {
64
+ // Clamped: a sample outside -1..1 is legal in Web Audio and wraps rather
65
+ // than clips once it is an integer, which is heard as a loud crack.
66
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
67
+ out.setInt16(i * 2, Math.round(clamped * FULL_SCALE), true);
68
+ }
69
+ return out.buffer;
70
+ }
71
+ export class PcmRecorder {
72
+ constructor(options) {
73
+ /** Whether chunks may still be delivered.
74
+ *
75
+ * **Cleared synchronously by `stop()`, and checked before every delivery.**
76
+ * Disconnecting a node does not un-post the messages it has already sent:
77
+ * the worklet runs on the audio thread and its `port.postMessage` calls
78
+ * arrive on the main thread as ordinary tasks, so a handful land *after*
79
+ * `stop()` has returned.
80
+ *
81
+ * That is not a tidiness problem. The caller sends each chunk into an open
82
+ * `user_audio` window and closes that window immediately after stopping —
83
+ * so a late chunk is binary audio arriving with no window open, which the
84
+ * edge answers by closing the connection. That is how it shows: hold,
85
+ * release, hold, release, and the socket dies with
86
+ * "Binary audio arrived with no user_audio.start before it."
87
+ */
88
+ this.live = false;
89
+ this.context = null;
90
+ this.stream = null;
91
+ this.node = null;
92
+ this.source = null;
93
+ /** Samples not yet delivered — see `FRAME_SAMPLES`. */
94
+ this.pending = [];
95
+ this.pendingSamples = 0;
96
+ this.onChunk = options.onChunk;
97
+ this.sampleRate = options.sampleRate ?? RECORD_SAMPLE_RATE;
98
+ }
99
+ /** True once `start()` has a live microphone and has not been stopped. */
100
+ get recording() {
101
+ return this.live;
102
+ }
103
+ /**
104
+ * Hz the delivered samples actually are.
105
+ *
106
+ * The context is *asked* for `sampleRate`; browsers honour it or throw, and
107
+ * this reads back what was granted rather than repeating the request. It is
108
+ * what a caller must declare on `user_audio.start` — a declared rate the
109
+ * samples are not is the wrong pitch and a wrong bill, behind a 200.
110
+ */
111
+ get rate() {
112
+ return this.context?.sampleRate ?? this.sampleRate;
113
+ }
114
+ /**
115
+ * Ask for the microphone and begin delivering samples.
116
+ *
117
+ * Rejects if permission is refused, which is the caller's to report — a mic
118
+ * button that silently does nothing after a denied prompt is the worst of the
119
+ * available outcomes.
120
+ */
121
+ async start() {
122
+ if (this.stream)
123
+ return;
124
+ this.stream = await navigator.mediaDevices.getUserMedia({
125
+ audio: {
126
+ // The browser's own cleanup, which is better than anything worth doing
127
+ // here and costs nothing: a recogniser fed room echo transcribes it.
128
+ echoCancellation: true,
129
+ noiseSuppression: true,
130
+ autoGainControl: true,
131
+ channelCount: 1,
132
+ },
133
+ });
134
+ try {
135
+ this.context = new AudioContext({ sampleRate: this.sampleRate });
136
+ // Autoplay policy suspends a context created outside a gesture. Pressing
137
+ // a microphone button *is* one, so this resolves immediately — it is here
138
+ // for the case where the button was held from a restored tab.
139
+ if (this.context.state === "suspended")
140
+ await this.context.resume();
141
+ this.source = this.context.createMediaStreamSource(this.stream);
142
+ this.node = await this.tap(this.context);
143
+ }
144
+ catch (failure) {
145
+ // **The track is released on the way out** (console 0.197.0).
146
+ // `getUserMedia` had succeeded, so a context that refused the rate or a
147
+ // worklet that failed to load used to leave a live microphone —
148
+ // indicator on — behind an error the caller reported as "could not
149
+ // open the microphone".
150
+ this.stop();
151
+ throw failure;
152
+ }
153
+ // **Last, after everything that can fail.** Connecting is what starts the
154
+ // samples flowing, so anything between it and here would be a window in
155
+ // which chunks arrive before the caller has been told recording began.
156
+ this.live = true;
157
+ this.source.connect(this.node);
158
+ // **Not connected to the destination.** Routing the microphone to the
159
+ // speakers is feedback, and a `ScriptProcessorNode` needs a downstream to
160
+ // be pulled at all — so that fallback connects to a muted gain instead.
161
+ }
162
+ /**
163
+ * Stop, release the microphone, and turn the browser's indicator off.
164
+ *
165
+ * Returns the **tail**: samples batched and not yet delivered when the button
166
+ * was released — up to one frame, ~85 ms, the end of the last word. Returned
167
+ * rather than delivered through `onChunk`, because the caller has by now
168
+ * stopped accepting chunks (`stop()` is what it calls to stop them) and
169
+ * decides for itself whether the tail goes on the wire before `user_audio.end`.
170
+ * `null` when there is nothing held.
171
+ */
172
+ stop() {
173
+ // **First, before anything else.** Everything below is asynchronous in
174
+ // effect — a disconnected node's already-posted messages still arrive — and
175
+ // this is the only part that takes effect immediately.
176
+ this.live = false;
177
+ const tail = this.pendingSamples ? toPcm16(this.takePending()) : null;
178
+ this.source?.disconnect();
179
+ this.node?.disconnect();
180
+ for (const track of this.stream?.getTracks() ?? [])
181
+ track.stop();
182
+ void this.context?.close();
183
+ this.source = null;
184
+ this.node = null;
185
+ this.stream = null;
186
+ this.context = null;
187
+ return tail;
188
+ }
189
+ /** Batch a block of samples, delivering whole frames as they fill. */
190
+ deliver(samples) {
191
+ if (!this.live)
192
+ return;
193
+ this.pending.push(samples);
194
+ this.pendingSamples += samples.length;
195
+ while (this.pendingSamples >= FRAME_SAMPLES) {
196
+ this.onChunk(toPcm16(this.takePending(FRAME_SAMPLES)));
197
+ }
198
+ }
199
+ /** Up to `count` pending samples (all of them by default), as one block. */
200
+ takePending(count = this.pendingSamples) {
201
+ const out = new Float32Array(Math.min(count, this.pendingSamples));
202
+ let filled = 0;
203
+ while (filled < out.length) {
204
+ const head = this.pending[0];
205
+ const take = Math.min(head.length, out.length - filled);
206
+ out.set(head.subarray(0, take), filled);
207
+ filled += take;
208
+ if (take === head.length)
209
+ this.pending.shift();
210
+ else
211
+ this.pending[0] = head.subarray(take);
212
+ }
213
+ this.pendingSamples -= out.length;
214
+ return out;
215
+ }
216
+ async tap(context) {
217
+ if (context.audioWorklet) {
218
+ try {
219
+ const url = URL.createObjectURL(new Blob([WORKLET], { type: "application/javascript" }));
220
+ try {
221
+ await context.audioWorklet.addModule(url);
222
+ }
223
+ finally {
224
+ // Revoked as soon as it is loaded: the module is compiled by then,
225
+ // and a blob URL left behind is a leak for the tab's lifetime.
226
+ URL.revokeObjectURL(url);
227
+ }
228
+ const node = new AudioWorkletNode(context, "pcm-tap");
229
+ node.port.onmessage = (event) => {
230
+ this.deliver(event.data);
231
+ };
232
+ return node;
233
+ }
234
+ catch {
235
+ // swallowed: fall through. Worklets are unavailable over plain HTTP on
236
+ // some browsers and the fallback is correct, just less pleasant.
237
+ }
238
+ }
239
+ // `ScriptProcessorNode` is deprecated and still universally supported. It
240
+ // runs on the main thread, so a heavy render can drop a block — acceptable
241
+ // as a fallback, not as the default.
242
+ const node = context.createScriptProcessor(FRAME_SAMPLES, 1, 0);
243
+ node.onaudioprocess = (event) => {
244
+ // Copied: the buffer is the node's and is reused for the next block.
245
+ this.deliver(event.inputBuffer.getChannelData(0).slice());
246
+ };
247
+ // Silent sink: without a downstream connection the node is never pulled and
248
+ // `onaudioprocess` never fires, which reads as a dead microphone.
249
+ const sink = context.createGain();
250
+ sink.gain.value = 0;
251
+ node.connect(sink);
252
+ sink.connect(context.destination);
253
+ return node;
254
+ }
255
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The browser's player and recorder as an `AgentAudio`: what
3
+ * `@lumavox/react`'s `useAgent` plays every reply through and records with
4
+ * — the console's since client 0.8.0, which it was written for.
5
+ *
6
+ * - **The player is made on the first click** (`prime`): every browser
7
+ * suspends an `AudioContext` created outside a user gesture, and a
8
+ * suspended one accepts every scheduling call silently.
9
+ * - **Each frame says its rate**: `audio.start` carries the window's own
10
+ * (the agent's voice is re-read every turn), and the player makes each
11
+ * buffer at it.
12
+ * - **The recorder is loaded on the first press**: `recorder.ts` holds the
13
+ * AudioWorklet setup, kept out of a page that never records. It records
14
+ * at 24 kHz whatever is asked — a rate the socket takes. Its tail — the
15
+ * last partial frame, the end of the last word — is delivered before
16
+ * `stopCapture` settles, or the transcript never gets it.
17
+ * - **Hands-free where the browser cancels echo** (`handsFree`): Chrome,
18
+ * Edge and Firefox remove a tab's own sound from its microphone — Firefox
19
+ * only with the context at the device's rate, which the player keeps.
20
+ * Every browser on an iPhone or iPad is WebKit, whose canceller fails
21
+ * three ways (WebKit bugs 218012, 311451, 326286): refused there.
22
+ */
23
+ import type { AgentAudio } from "../audio.js";
24
+ /** Why hands-free is refused on an iPhone or iPad. */
25
+ export declare const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use push-to-talk.";
26
+ /** Why it is refused in a browser that cannot cancel echo at all. */
27
+ export declare const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
28
+ export declare class WebAudio implements AgentAudio {
29
+ private player;
30
+ private recorder;
31
+ private onPcm;
32
+ private readonly drained;
33
+ /** Make the player and unlock it. **Call it from the click**, before any `await`. */
34
+ prime(): Promise<void>;
35
+ play(pcm: ArrayBuffer, frame: {
36
+ sample_rate: number;
37
+ }): void;
38
+ stop(): void;
39
+ onDrained(fn: () => void): () => void;
40
+ /** At the recorder's own rate, 24 kHz, whatever is asked. */
41
+ startCapture(onPcm: (pcm: ArrayBuffer) => void, _options?: {
42
+ rate?: number;
43
+ }): Promise<{
44
+ rate: number;
45
+ }>;
46
+ stopCapture(): Promise<void>;
47
+ duck(on: boolean): void;
48
+ handsFree(): Promise<{
49
+ ok: true;
50
+ } | {
51
+ ok: false;
52
+ reason: string;
53
+ }>;
54
+ /** The conversation is over: the player's context goes too. */
55
+ dispose(): void;
56
+ }
@@ -0,0 +1,85 @@
1
+ import { PcmPlayer } from "./player.js";
2
+ /** The reply's loudness while the person may be talking over it: about -10 dB. */
3
+ const DUCKED = 0.3;
4
+ /** Why hands-free is refused on an iPhone or iPad. */
5
+ export const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use push-to-talk.";
6
+ /** Why it is refused in a browser that cannot cancel echo at all. */
7
+ export const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
8
+ export class WebAudio {
9
+ constructor() {
10
+ this.player = null;
11
+ this.recorder = null;
12
+ this.onPcm = null;
13
+ this.drained = new Set();
14
+ }
15
+ /** Make the player and unlock it. **Call it from the click**, before any `await`. */
16
+ prime() {
17
+ this.player ?? (this.player = new PcmPlayer({
18
+ onIdle: () => {
19
+ for (const fn of this.drained)
20
+ fn();
21
+ },
22
+ }));
23
+ return this.player.resume();
24
+ }
25
+ play(pcm, frame) {
26
+ if (!this.player)
27
+ void this.prime();
28
+ const player = this.player;
29
+ player.setSampleRate(frame.sample_rate);
30
+ player.push(pcm);
31
+ }
32
+ stop() {
33
+ this.player?.stop();
34
+ }
35
+ onDrained(fn) {
36
+ this.drained.add(fn);
37
+ return () => {
38
+ this.drained.delete(fn);
39
+ };
40
+ }
41
+ /** At the recorder's own rate, 24 kHz, whatever is asked. */
42
+ async startCapture(onPcm, _options) {
43
+ const { PcmRecorder } = await import("./recorder.js");
44
+ const mic = new PcmRecorder({ onChunk: (pcm) => this.onPcm?.(pcm) });
45
+ this.onPcm = onPcm;
46
+ this.recorder = mic;
47
+ try {
48
+ await mic.start();
49
+ }
50
+ catch (failure) {
51
+ if (this.recorder === mic)
52
+ this.recorder = null;
53
+ throw failure;
54
+ }
55
+ return { rate: mic.rate };
56
+ }
57
+ async stopCapture() {
58
+ const mic = this.recorder;
59
+ const deliver = this.onPcm;
60
+ this.recorder = null;
61
+ this.onPcm = null;
62
+ const tail = mic?.stop();
63
+ if (tail)
64
+ deliver?.(tail);
65
+ }
66
+ duck(on) {
67
+ this.player?.setVolume(on ? DUCKED : 1);
68
+ }
69
+ async handsFree() {
70
+ const devices = typeof navigator === "undefined" ? undefined : navigator.mediaDevices;
71
+ if (!devices?.getSupportedConstraints?.().echoCancellation) {
72
+ return { ok: false, reason: NO_ECHO };
73
+ }
74
+ // iPadOS says it is a Mac: a Mac with a touch screen is an iPad.
75
+ const ios = /iP(hone|ad|od)/.test(navigator.userAgent) ||
76
+ (/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
77
+ return ios ? { ok: false, reason: IOS_ECHO } : { ok: true };
78
+ }
79
+ /** The conversation is over: the player's context goes too. */
80
+ dispose() {
81
+ void this.stopCapture();
82
+ void this.player?.close();
83
+ this.player = null;
84
+ }
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumavox/client",
3
- "version": "0.7.0",
3
+ "version": "0.10.0",
4
4
  "description": "Talk to Lumavox agents from a browser, a React Native app or Node, with an end user's ephemeral key: conversations on the agent's socket, sessions, memory and relations. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,6 +16,11 @@
16
16
  "types": "./dist/index.d.ts",
17
17
  "default": "./dist/index.js"
18
18
  },
19
+ "./web-audio": {
20
+ "@ino/source": "./src/web-audio/index.ts",
21
+ "types": "./dist/web-audio/index.d.ts",
22
+ "default": "./dist/web-audio/index.js"
23
+ },
19
24
  "./package.json": "./package.json"
20
25
  },
21
26
  "scripts": {
@@ -27,14 +32,18 @@
27
32
  "format": "biome format --write",
28
33
  "lint": "biome lint",
29
34
  "test": "vitest run",
30
- "test:unit": "vitest run",
35
+ "test:unit": "vitest run --project unit",
36
+ "test:browser": "vitest run --project browser",
31
37
  "typecheck": "tsc --noEmit && tsc --noEmit -p test"
32
38
  },
33
39
  "devDependencies": {
34
40
  "@biomejs/biome": "2.6.2",
35
41
  "@hey-api/openapi-ts": "^0.99.0",
36
42
  "@types/node": "^24",
43
+ "@vitest/browser": "^4.1.12",
44
+ "@vitest/browser-playwright": "^4.1.12",
37
45
  "esbuild": "^0.28.1",
46
+ "playwright": "^1.64.1",
38
47
  "typescript": "^6",
39
48
  "vitest": "^4.1.12"
40
49
  }