@lumavox/client 0.6.0 → 0.9.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/README.md CHANGED
@@ -29,7 +29,9 @@ const outcome = await conversation.send("What should I see on my first morning?"
29
29
  // { status: "done", frame } | { status: "interrupted", frame } | { status: "dropped", reason, message }
30
30
  ```
31
31
 
32
- React and React Native: `@lumavox/react`, hooks over this.
32
+ React: `@lumavox/react`, hooks over this. A React Native app:
33
+ `@lumavox/react-native`, the hooks and this with push-to-talk audio, in one
34
+ install.
33
35
 
34
36
  The guide, with every example: the docs' **SDKs** tab, *Client SDK*.
35
37
 
@@ -126,11 +128,27 @@ const turn = await conversation.endAudio(); // a spoken turn
126
128
  const words = await conversation.endAudio({ transcribeOnly: true });
127
129
  ```
128
130
 
129
- The SDK captures and plays nothing itself: a recorder hands it samples, a
130
- player takes `onAudio`'s. `floatToPcm16`, `pcm16ToFloat`, `resample`,
131
- `concatPcm` and `pcmSeconds` convert between them and the wire;
131
+ `createClient` captures and plays nothing itself: a recorder hands it
132
+ samples, a player takes `onAudio`'s. `floatToPcm16`, `pcm16ToFloat`,
133
+ `resample`, `concatPcm` and `pcmSeconds` convert between them and the wire;
132
134
  `AgentAudio` is the interface `@lumavox/react` drives.
133
135
 
136
+ ### Audio in a browser
137
+
138
+ ```ts
139
+ import { createWebAudio } from "@lumavox/client/web-audio";
140
+
141
+ const audio = createWebAudio();
142
+ startButton.onclick = () => audio.prime(); // in the click: browsers start sound only from one
143
+ ```
144
+
145
+ An `AgentAudio` on Web Audio, its own import so the client carries none of
146
+ it (~5.5 KB minified): replies play gaplessly as they arrive, at the
147
+ device's own rate; the microphone records at 24 kHz, with the browser's
148
+ echo cancellation, noise suppression and gain control. Hand it to
149
+ `@lumavox/react`'s `useAgent`, or call `play`, `startCapture` and the rest
150
+ yourself.
151
+
134
152
  ## Runtimes
135
153
 
136
154
  Browsers, React Native (Hermes, ES2020 — and each binary frame reaches JS
package/dist/audio.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * What an app plugs in to make a conversation audible and to speak in it:
3
- * a player and a recorder. **Types only in v1**: the SDK captures and
4
- * plays nothing itself, so it adds no audio library to a customer's app —
5
- * the console's Web Audio player and recorder are the first implementation
6
- * (`apps/console/src/components/audio/agent-audio.ts`), and
7
- * `@lumavox/react`'s `useAgent` drives one: it plays every audio frame,
8
- * stops on a cancel, a mute or a new turn, and records at the rate the
9
- * recorder was granted. Voice activity detection stays the app's.
3
+ * a player and a recorder. `createClient` captures and plays nothing
4
+ * itself, so it adds no audio to a customer's app; a browser's is the
5
+ * optional `@lumavox/client/web-audio` (`src/web-audio/`), a phone's
6
+ * `@lumavox/react-native`'s, and an app may write its own. `@lumavox/react`'s
7
+ * `useAgent` drives one: it plays every audio frame, stops on a cancel, a
8
+ * mute or a new turn, records at the rate the recorder was granted, and
9
+ * throws away a recording the device interrupted.
10
+ * Voice activity detection stays the app's.
10
11
  */
11
12
  export interface AgentAudio {
12
13
  /** Queue one frame of a reply: 16-bit little-endian PCM, in wire order. */
@@ -35,4 +36,13 @@ export interface AgentAudio {
35
36
  stopCapture(): Promise<void>;
36
37
  /** Free everything: the conversation is over. */
37
38
  dispose(): void;
39
+ /**
40
+ * Optional: `fn(true)` when the device takes the audio away — a phone
41
+ * call, Siri, another app, the app leaving the screen — and `fn(false)`
42
+ * when it gives it back. The adapter suspends playback (a reply's frames
43
+ * keep queueing) and closes its recorder itself; whoever drives it throws
44
+ * away the recording that was open, its speaker cut off mid-sentence.
45
+ * Answers the unsubscribe.
46
+ */
47
+ onInterruption?(fn: (interrupted: boolean) => void): () => void;
38
48
  }
package/dist/events.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * test (`apps/edge/test/sdk.test.ts`) holds every field of every frame the
10
10
  * real conversation object sends to these, both ways.
11
11
  */
12
- import type { ErrorCode, RelationInfo } from "./gen/index.js";
12
+ import type { ErrorCode, MessageSource, RelationInfo } from "./gen/index.js";
13
13
  import type { ServerEventName } from "./protocol.gen.js";
14
14
  interface Frame<T extends ServerEventName> {
15
15
  type: T;
@@ -207,6 +207,8 @@ export interface TurnDoneFrame extends Frame<"turn.done">, InTurn {
207
207
  heard_seconds: number | null;
208
208
  /** Your tools it called, in order. */
209
209
  tool_calls: ToolCall[];
210
+ /** The web pages it read, with the agent's `url_context` on; `[]` for none. */
211
+ sources: MessageSource[];
210
212
  timings: TurnTimings;
211
213
  }
212
214
  /** The turn ended early; what was written before it stopped still stands. */
@@ -1 +1 @@
1
- export type { ClientOptions, CompactionInfo, CompactRequest, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteAllFactsData, DeleteAllFactsError, DeleteAllFactsErrors, DeleteAllFactsResponse, DeleteAllFactsResponses, DeleteFactData, DeleteFactError, DeleteFactErrors, DeleteFactResponse, DeleteFactResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, ErrorCode, ErrorInfo, ErrorResponse, ExtractSessionFactsData, ExtractSessionFactsError, ExtractSessionFactsErrors, ExtractSessionFactsResponse, ExtractSessionFactsResponses, FactInfo, FactPage, FactsDeleted, GetSessionData, GetSessionError, GetSessionErrors, GetSessionResponse, GetSessionResponses, KnowledgeCitation, KnowledgeLookupInfo, ListFactsData, ListFactsError, ListFactsErrors, ListFactsResponse, ListFactsResponses, ListRelationsData, ListRelationsError, ListRelationsErrors, ListRelationsResponse, ListRelationsResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, MessageInfo, MessageKnowledge, MessagePage, MessageToolCall, RelationInfo, RelationPage, SessionCreateRequest, SessionDetail, SessionInfo, SessionPage, ValidationIssue } from './types.gen.js';
1
+ export type { ClientOptions, CompactionInfo, CompactRequest, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteAllFactsData, DeleteAllFactsError, DeleteAllFactsErrors, DeleteAllFactsResponse, DeleteAllFactsResponses, DeleteFactData, DeleteFactError, DeleteFactErrors, DeleteFactResponse, DeleteFactResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, ErrorCode, ErrorInfo, ErrorResponse, ExtractSessionFactsData, ExtractSessionFactsError, ExtractSessionFactsErrors, ExtractSessionFactsResponse, ExtractSessionFactsResponses, FactInfo, FactPage, FactsDeleted, GetSessionData, GetSessionError, GetSessionErrors, GetSessionResponse, GetSessionResponses, KnowledgeCitation, KnowledgeLookupInfo, ListFactsData, ListFactsError, ListFactsErrors, ListFactsResponse, ListFactsResponses, ListRelationsData, ListRelationsError, ListRelationsErrors, ListRelationsResponse, ListRelationsResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, MessageInfo, MessageKnowledge, MessagePage, MessageSource, MessageToolCall, RelationInfo, RelationPage, SessionCreateRequest, SessionDetail, SessionInfo, SessionPage, ValidationIssue } from './types.gen.js';
@@ -89,6 +89,10 @@ export type MessageInfo = {
89
89
  */
90
90
  mood_intensity: number | null;
91
91
  role: 'user' | 'assistant';
92
+ /**
93
+ * On a reply: the web pages it read, with the agent's `url_context` switch on. Empty when it read none, and on the person's messages.
94
+ */
95
+ sources: Array<MessageSource>;
92
96
  /**
93
97
  * On a reply: the calls of your tools it made, in order, each with its arguments and what it answered. Empty when it called none, and on the person's messages.
94
98
  */
@@ -118,6 +122,16 @@ export type KnowledgeCitation = {
118
122
  score: number;
119
123
  title: string;
120
124
  };
125
+ /**
126
+ * A web page the reply read with the agent's `url_context` switch on.
127
+ */
128
+ export type MessageSource = {
129
+ url: string;
130
+ /**
131
+ * Whether the page could be read. A reply may name a page it could not fetch — behind a login, refused by its site, gone — and answers without it.
132
+ */
133
+ retrieved: boolean;
134
+ };
121
135
  export type MessageToolCall = {
122
136
  /**
123
137
  * The call's id, `call_…`: what your server or app was sent.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@lumavox/client/web-audio`: a browser's player and recorder for a
3
+ * conversation, on Web Audio — an `AgentAudio` that `@lumavox/react`'s
4
+ * `useAgent` drives. An entry of its own, so `createClient` carries none of
5
+ * it (`test/dist.test.ts` holds both sizes); zero dependencies, as the rest.
6
+ *
7
+ * ```ts
8
+ * const audio = createWebAudio();
9
+ * button.onclick = () => audio.prime(); // in the click: browsers start audio only from one
10
+ * ```
11
+ */
12
+ import { WebAudio } from "./web-audio.js";
13
+ export type { WebAudio };
14
+ /** A player and a recorder for one conversation. Call `prime()` from the click that starts it. */
15
+ export declare function createWebAudio(): WebAudio;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `@lumavox/client/web-audio`: a browser's player and recorder for a
3
+ * conversation, on Web Audio — an `AgentAudio` that `@lumavox/react`'s
4
+ * `useAgent` drives. An entry of its own, so `createClient` carries none of
5
+ * it (`test/dist.test.ts` holds both sizes); zero dependencies, as the rest.
6
+ *
7
+ * ```ts
8
+ * const audio = createWebAudio();
9
+ * button.onclick = () => audio.prime(); // in the click: browsers start audio only from one
10
+ * ```
11
+ */
12
+ import { WebAudio } from "./web-audio.js";
13
+ /** A player and a recorder for one conversation. Call `prime()` from the click that starts it. */
14
+ export function createWebAudio() {
15
+ return new WebAudio();
16
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Playing raw PCM as it arrives, with no container and no `<audio>` element.
3
+ *
4
+ * **Why not `<audio>`.** That needs a container — a data URL with a MIME
5
+ * type — and the socket sends bare samples precisely to avoid one: base64 is
6
+ * 33% of every audio byte, and audio is most of a spoken turn. There is also
7
+ * nothing to point an `<audio>` element *at* until a chunk is complete, which
8
+ * would give back the latency the transport exists to win.
9
+ *
10
+ * So: Web Audio, one `AudioBufferSourceNode` per blob, each scheduled to start
11
+ * exactly where the last one ended. Three things make that sound continuous
12
+ * rather than like a stutter of clips:
13
+ *
14
+ * - **The schedule is kept in the context's own clock**, never in wall time.
15
+ * `playhead` is an absolute `AudioContext.currentTime`, so scheduling is
16
+ * sample-accurate and drift-free even if the tab is busy.
17
+ * - **A blob arriving late restarts from `currentTime`**, not from a playhead
18
+ * in the past — otherwise it is scheduled to have already happened and is
19
+ * simply dropped, which is heard as a missing sentence.
20
+ * - **A tiny lead-in** (`SCHEDULE_AHEAD`) absorbs the jitter between a frame
21
+ * arriving and it being decoded, at the cost of a few milliseconds.
22
+ *
23
+ * **The context runs at the device's own rate**; each buffer is made at the
24
+ * stream's (24 kHz unless `audio.start` says otherwise) and the browser
25
+ * resamples it as it plays — every browser does, as Web Audio requires.
26
+ * Firefox's echo canceller hears only a context at the device's rate, which
27
+ * hands-free will need; until the console's code became this module (client
28
+ * 0.8.0) the context was pinned to the stream's rate and rebuilt when it
29
+ * changed.
30
+ *
31
+ * Nothing here decodes: the format is 16-bit signed little-endian, so it is
32
+ * an `Int16Array` read straight into a `Float32Array`.
33
+ */
34
+ export interface PcmPlayerOptions {
35
+ /** Hz of the PCM pushed, until `setSampleRate`: the socket's default. */
36
+ sampleRate?: number;
37
+ channels?: number;
38
+ /** Called when the queue drains — every scheduled source has finished. */
39
+ onIdle?: () => void;
40
+ }
41
+ export declare class PcmPlayer {
42
+ private context;
43
+ private gain;
44
+ private playhead;
45
+ /** A trailing byte from the previous frame, when one was split. */
46
+ private remainder;
47
+ private scheduled;
48
+ /** Bumped by `stop()`, so a source cut off by it does not count itself out of
49
+ * a queue that has already been zeroed — `scheduled` went negative after a
50
+ * barge-in until console 0.197.0, and `playing` lied from then on. */
51
+ private generation;
52
+ private sampleRate;
53
+ private readonly channels;
54
+ private readonly onIdle?;
55
+ constructor(options?: PcmPlayerOptions);
56
+ /**
57
+ * The rate of the PCM pushed from here on: each `audio.start` carries its
58
+ * window's own, since an agent's voice may be edited mid-conversation.
59
+ * What is queued plays on at its own rate — every buffer carries it — so
60
+ * nothing is dropped and the context stays.
61
+ */
62
+ setSampleRate(rate: number): void;
63
+ /**
64
+ * Create the context, or resume one the browser suspended.
65
+ *
66
+ * **Must be called from a user gesture the first time.** Every browser
67
+ * suspends a context created outside one, and a suspended context accepts
68
+ * every scheduling call silently — so the symptom is a conversation that
69
+ * looks like it is speaking and makes no sound.
70
+ */
71
+ resume(): Promise<void>;
72
+ /** Whether anything is scheduled and still to play. */
73
+ get playing(): boolean;
74
+ /**
75
+ * Queue one blob of PCM.
76
+ *
77
+ * Ignored before {@link resume} — a caller that pushes audio into a player
78
+ * nobody unlocked has a gesture problem, and buffering it would hide that
79
+ * until the sound arrived minutes later in one burst.
80
+ */
81
+ push(pcm: ArrayBuffer): void;
82
+ /**
83
+ * Stop everything now — barge-in, or the turn being cancelled.
84
+ *
85
+ * Disconnecting the gain node rather than stopping each source: sources are
86
+ * fire-and-forget once scheduled and holding references to them purely to
87
+ * stop them is a leak waiting to happen. A fresh node is built on the next
88
+ * push.
89
+ */
90
+ stop(): void;
91
+ /** Release the audio device. Safe to call more than once. */
92
+ close(): Promise<void>;
93
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Playing raw PCM as it arrives, with no container and no `<audio>` element.
3
+ *
4
+ * **Why not `<audio>`.** That needs a container — a data URL with a MIME
5
+ * type — and the socket sends bare samples precisely to avoid one: base64 is
6
+ * 33% of every audio byte, and audio is most of a spoken turn. There is also
7
+ * nothing to point an `<audio>` element *at* until a chunk is complete, which
8
+ * would give back the latency the transport exists to win.
9
+ *
10
+ * So: Web Audio, one `AudioBufferSourceNode` per blob, each scheduled to start
11
+ * exactly where the last one ended. Three things make that sound continuous
12
+ * rather than like a stutter of clips:
13
+ *
14
+ * - **The schedule is kept in the context's own clock**, never in wall time.
15
+ * `playhead` is an absolute `AudioContext.currentTime`, so scheduling is
16
+ * sample-accurate and drift-free even if the tab is busy.
17
+ * - **A blob arriving late restarts from `currentTime`**, not from a playhead
18
+ * in the past — otherwise it is scheduled to have already happened and is
19
+ * simply dropped, which is heard as a missing sentence.
20
+ * - **A tiny lead-in** (`SCHEDULE_AHEAD`) absorbs the jitter between a frame
21
+ * arriving and it being decoded, at the cost of a few milliseconds.
22
+ *
23
+ * **The context runs at the device's own rate**; each buffer is made at the
24
+ * stream's (24 kHz unless `audio.start` says otherwise) and the browser
25
+ * resamples it as it plays — every browser does, as Web Audio requires.
26
+ * Firefox's echo canceller hears only a context at the device's rate, which
27
+ * hands-free will need; until the console's code became this module (client
28
+ * 0.8.0) the context was pinned to the stream's rate and rebuilt when it
29
+ * changed.
30
+ *
31
+ * Nothing here decodes: the format is 16-bit signed little-endian, so it is
32
+ * an `Int16Array` read straight into a `Float32Array`.
33
+ */
34
+ import { DEFAULT_SAMPLE_RATE } from "../protocol.gen.js";
35
+ /** How far ahead of `currentTime` a freshly-started run begins. Enough to
36
+ * absorb frame jitter, short enough not to be perceived as delay. */
37
+ const SCHEDULE_AHEAD = 0.06;
38
+ /** Divisor from 16-bit signed to the -1..1 floats Web Audio wants. 32768,
39
+ * because the negative range is one larger than the positive one. */
40
+ const FULL_SCALE = 32768;
41
+ export class PcmPlayer {
42
+ constructor(options = {}) {
43
+ this.context = null;
44
+ this.gain = null;
45
+ this.playhead = 0;
46
+ /** A trailing byte from the previous frame, when one was split. */
47
+ this.remainder = new Uint8Array(0);
48
+ this.scheduled = 0;
49
+ /** Bumped by `stop()`, so a source cut off by it does not count itself out of
50
+ * a queue that has already been zeroed — `scheduled` went negative after a
51
+ * barge-in until console 0.197.0, and `playing` lied from then on. */
52
+ this.generation = 0;
53
+ // The edge's default (the vocabulary's), until an `audio.start` says
54
+ // otherwise.
55
+ this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
56
+ this.channels = options.channels ?? 1;
57
+ this.onIdle = options.onIdle;
58
+ }
59
+ /**
60
+ * The rate of the PCM pushed from here on: each `audio.start` carries its
61
+ * window's own, since an agent's voice may be edited mid-conversation.
62
+ * What is queued plays on at its own rate — every buffer carries it — so
63
+ * nothing is dropped and the context stays.
64
+ */
65
+ setSampleRate(rate) {
66
+ if (!Number.isFinite(rate) || rate <= 0)
67
+ return;
68
+ this.sampleRate = rate;
69
+ }
70
+ /**
71
+ * Create the context, or resume one the browser suspended.
72
+ *
73
+ * **Must be called from a user gesture the first time.** Every browser
74
+ * suspends a context created outside one, and a suspended context accepts
75
+ * every scheduling call silently — so the symptom is a conversation that
76
+ * looks like it is speaking and makes no sound.
77
+ */
78
+ async resume() {
79
+ if (!this.context) {
80
+ this.context = new AudioContext();
81
+ this.gain = this.context.createGain();
82
+ this.gain.connect(this.context.destination);
83
+ }
84
+ if (this.context.state === "suspended")
85
+ await this.context.resume();
86
+ }
87
+ /** Whether anything is scheduled and still to play. */
88
+ get playing() {
89
+ return this.scheduled > 0;
90
+ }
91
+ /**
92
+ * Queue one blob of PCM.
93
+ *
94
+ * Ignored before {@link resume} — a caller that pushes audio into a player
95
+ * nobody unlocked has a gesture problem, and buffering it would hide that
96
+ * until the sound arrived minutes later in one burst.
97
+ */
98
+ push(pcm) {
99
+ const context = this.context;
100
+ if (!context)
101
+ return;
102
+ // **A split sample is carried, never dropped.** The edge sends whole
103
+ // samples per frame (`apps/edge/src/voice/tts.ts` holds an odd byte for
104
+ // the next blob), so this should never fire — but truncating instead is
105
+ // the bug that made ElevenLabs playback white noise, and it is worth being
106
+ // unable to reintroduce. Dropping the odd byte does not lose one sample,
107
+ // it shifts every byte after it by one and turns the rest of the frame
108
+ // into noise until something realigns it.
109
+ let bytes = new Uint8Array(pcm);
110
+ if (this.remainder.length) {
111
+ const joined = new Uint8Array(this.remainder.length + bytes.length);
112
+ joined.set(this.remainder, 0);
113
+ joined.set(bytes, this.remainder.length);
114
+ bytes = joined;
115
+ }
116
+ const usable = bytes.length - (bytes.length % 2);
117
+ this.remainder = bytes.slice(usable);
118
+ if (usable < 2)
119
+ return;
120
+ // Copied into its own buffer rather than viewed in place: `Int16Array`
121
+ // needs a 2-byte-aligned offset, and a frame joined to a carried byte has
122
+ // no such promise.
123
+ const samples = new Int16Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + usable));
124
+ // At the stream's rate, not the context's: the browser resamples it.
125
+ const buffer = context.createBuffer(this.channels, samples.length / this.channels, this.sampleRate);
126
+ for (let channel = 0; channel < this.channels; channel += 1) {
127
+ const target = buffer.getChannelData(channel);
128
+ for (let i = 0; i < target.length; i += 1)
129
+ target[i] = samples[i * this.channels + channel] / FULL_SCALE;
130
+ }
131
+ const source = context.createBufferSource();
132
+ source.buffer = buffer;
133
+ source.connect(this.gain);
134
+ // **The branch that keeps it gapless.** While audio is still queued, the
135
+ // next blob starts exactly where the last one ends; once the queue has run
136
+ // dry, the playhead is in the past and scheduling against it would mean
137
+ // "start before now", which the API resolves as "start immediately" for the
138
+ // first blob and drops for every one after it.
139
+ const now = context.currentTime;
140
+ const startAt = this.playhead > now ? this.playhead : now + SCHEDULE_AHEAD;
141
+ source.start(startAt);
142
+ this.playhead = startAt + buffer.duration;
143
+ this.scheduled += 1;
144
+ const generation = this.generation;
145
+ source.onended = () => {
146
+ if (generation !== this.generation)
147
+ return;
148
+ this.scheduled -= 1;
149
+ if (this.scheduled === 0)
150
+ this.onIdle?.();
151
+ };
152
+ }
153
+ /**
154
+ * Stop everything now — barge-in, or the turn being cancelled.
155
+ *
156
+ * Disconnecting the gain node rather than stopping each source: sources are
157
+ * fire-and-forget once scheduled and holding references to them purely to
158
+ * stop them is a leak waiting to happen. A fresh node is built on the next
159
+ * push.
160
+ */
161
+ stop() {
162
+ if (!this.context || !this.gain)
163
+ return;
164
+ this.gain.disconnect();
165
+ this.gain = this.context.createGain();
166
+ this.gain.connect(this.context.destination);
167
+ this.playhead = 0;
168
+ const wasPlaying = this.scheduled > 0;
169
+ this.scheduled = 0;
170
+ this.generation += 1;
171
+ // A half sample belonging to audio that will never play now.
172
+ this.remainder = new Uint8Array(0);
173
+ if (wasPlaying)
174
+ this.onIdle?.();
175
+ }
176
+ /** Release the audio device. Safe to call more than once. */
177
+ async close() {
178
+ const context = this.context;
179
+ this.context = null;
180
+ this.gain = null;
181
+ this.playhead = 0;
182
+ this.scheduled = 0;
183
+ this.remainder = new Uint8Array(0);
184
+ if (context && context.state !== "closed")
185
+ await context.close();
186
+ }
187
+ }
@@ -0,0 +1,114 @@
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 declare const RECORD_SAMPLE_RATE = 24000;
35
+ /** Samples per delivered chunk — about 85 ms at 24 kHz. Small enough that the
36
+ * upload overlaps the talking, large enough not to be one WebSocket frame per
37
+ * animation tick.
38
+ *
39
+ * **Applied to the worklet path too.** The worklet posts every 128-sample
40
+ * render quantum; one WebSocket frame a post is about 188 frames a second of
41
+ * 256 bytes, each a TLS record and a message the end user DO wakes for —
42
+ * sixteen times as many as at this size. Batched here, on the main thread,
43
+ * where the tail can be flushed synchronously when the button is released. */
44
+ export declare const FRAME_SAMPLES = 2048;
45
+ export interface PcmRecorderOptions {
46
+ /** Called with each block of samples, already 16-bit little-endian. */
47
+ onChunk: (pcm: ArrayBuffer) => void;
48
+ sampleRate?: number;
49
+ }
50
+ /** Float samples to 16-bit signed little-endian, which is what the wire wants. */
51
+ export declare function toPcm16(samples: Float32Array): ArrayBuffer;
52
+ export declare class PcmRecorder {
53
+ /** Whether chunks may still be delivered.
54
+ *
55
+ * **Cleared synchronously by `stop()`, and checked before every delivery.**
56
+ * Disconnecting a node does not un-post the messages it has already sent:
57
+ * the worklet runs on the audio thread and its `port.postMessage` calls
58
+ * arrive on the main thread as ordinary tasks, so a handful land *after*
59
+ * `stop()` has returned.
60
+ *
61
+ * That is not a tidiness problem. The caller sends each chunk into an open
62
+ * `user_audio` window and closes that window immediately after stopping —
63
+ * so a late chunk is binary audio arriving with no window open, which the
64
+ * edge answers by closing the connection. That is how it shows: hold,
65
+ * release, hold, release, and the socket dies with
66
+ * "Binary audio arrived with no user_audio.start before it."
67
+ */
68
+ private live;
69
+ private context;
70
+ private stream;
71
+ private node;
72
+ private source;
73
+ private readonly onChunk;
74
+ private readonly sampleRate;
75
+ /** Samples not yet delivered — see `FRAME_SAMPLES`. */
76
+ private pending;
77
+ private pendingSamples;
78
+ constructor(options: PcmRecorderOptions);
79
+ /** True once `start()` has a live microphone and has not been stopped. */
80
+ get recording(): boolean;
81
+ /**
82
+ * Hz the delivered samples actually are.
83
+ *
84
+ * The context is *asked* for `sampleRate`; browsers honour it or throw, and
85
+ * this reads back what was granted rather than repeating the request. It is
86
+ * what a caller must declare on `user_audio.start` — a declared rate the
87
+ * samples are not is the wrong pitch and a wrong bill, behind a 200.
88
+ */
89
+ get rate(): number;
90
+ /**
91
+ * Ask for the microphone and begin delivering samples.
92
+ *
93
+ * Rejects if permission is refused, which is the caller's to report — a mic
94
+ * button that silently does nothing after a denied prompt is the worst of the
95
+ * available outcomes.
96
+ */
97
+ start(): Promise<void>;
98
+ /**
99
+ * Stop, release the microphone, and turn the browser's indicator off.
100
+ *
101
+ * Returns the **tail**: samples batched and not yet delivered when the button
102
+ * was released — up to one frame, ~85 ms, the end of the last word. Returned
103
+ * rather than delivered through `onChunk`, because the caller has by now
104
+ * stopped accepting chunks (`stop()` is what it calls to stop them) and
105
+ * decides for itself whether the tail goes on the wire before `user_audio.end`.
106
+ * `null` when there is nothing held.
107
+ */
108
+ stop(): ArrayBuffer | null;
109
+ /** Batch a block of samples, delivering whole frames as they fill. */
110
+ private deliver;
111
+ /** Up to `count` pending samples (all of them by default), as one block. */
112
+ private takePending;
113
+ private tap;
114
+ }
@@ -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,40 @@
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
+ */
18
+ import type { AgentAudio } from "../audio.js";
19
+ export declare class WebAudio implements AgentAudio {
20
+ private player;
21
+ private recorder;
22
+ private onPcm;
23
+ private readonly drained;
24
+ /** Make the player and unlock it. **Call it from the click**, before any `await`. */
25
+ prime(): Promise<void>;
26
+ play(pcm: ArrayBuffer, frame: {
27
+ sample_rate: number;
28
+ }): void;
29
+ stop(): void;
30
+ onDrained(fn: () => void): () => void;
31
+ /** At the recorder's own rate, 24 kHz, whatever is asked. */
32
+ startCapture(onPcm: (pcm: ArrayBuffer) => void, _options?: {
33
+ rate?: number;
34
+ }): Promise<{
35
+ rate: number;
36
+ }>;
37
+ stopCapture(): Promise<void>;
38
+ /** The conversation is over: the player's context goes too. */
39
+ dispose(): void;
40
+ }
@@ -0,0 +1,66 @@
1
+ import { PcmPlayer } from "./player.js";
2
+ export class WebAudio {
3
+ constructor() {
4
+ this.player = null;
5
+ this.recorder = null;
6
+ this.onPcm = null;
7
+ this.drained = new Set();
8
+ }
9
+ /** Make the player and unlock it. **Call it from the click**, before any `await`. */
10
+ prime() {
11
+ this.player ?? (this.player = new PcmPlayer({
12
+ onIdle: () => {
13
+ for (const fn of this.drained)
14
+ fn();
15
+ },
16
+ }));
17
+ return this.player.resume();
18
+ }
19
+ play(pcm, frame) {
20
+ if (!this.player)
21
+ void this.prime();
22
+ const player = this.player;
23
+ player.setSampleRate(frame.sample_rate);
24
+ player.push(pcm);
25
+ }
26
+ stop() {
27
+ this.player?.stop();
28
+ }
29
+ onDrained(fn) {
30
+ this.drained.add(fn);
31
+ return () => {
32
+ this.drained.delete(fn);
33
+ };
34
+ }
35
+ /** At the recorder's own rate, 24 kHz, whatever is asked. */
36
+ async startCapture(onPcm, _options) {
37
+ const { PcmRecorder } = await import("./recorder.js");
38
+ const mic = new PcmRecorder({ onChunk: (pcm) => this.onPcm?.(pcm) });
39
+ this.onPcm = onPcm;
40
+ this.recorder = mic;
41
+ try {
42
+ await mic.start();
43
+ }
44
+ catch (failure) {
45
+ if (this.recorder === mic)
46
+ this.recorder = null;
47
+ throw failure;
48
+ }
49
+ return { rate: mic.rate };
50
+ }
51
+ async stopCapture() {
52
+ const mic = this.recorder;
53
+ const deliver = this.onPcm;
54
+ this.recorder = null;
55
+ this.onPcm = null;
56
+ const tail = mic?.stop();
57
+ if (tail)
58
+ deliver?.(tail);
59
+ }
60
+ /** The conversation is over: the player's context goes too. */
61
+ dispose() {
62
+ void this.stopCapture();
63
+ void this.player?.close();
64
+ this.player = null;
65
+ }
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumavox/client",
3
- "version": "0.6.0",
3
+ "version": "0.9.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
  }