@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.
@@ -4,7 +4,7 @@ export declare const PROTOCOL = "ino.v2";
4
4
  export declare const CLIENT_VERBS: readonly ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed", "tool.result"];
5
5
  export type ClientVerb = (typeof CLIENT_VERBS)[number];
6
6
  /** Every event the edge sends. */
7
- export declare const SERVER_EVENTS: readonly ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
7
+ export declare const SERVER_EVENTS: readonly ["ready", "turn.start", "heard", "heard.partial", "barge_in", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
8
8
  export type ServerEventName = (typeof SERVER_EVENTS)[number];
9
9
  /** The events that end a turn: exactly one of them ends every turn. */
10
10
  export declare const TERMINAL_EVENTS: readonly ["turn.done", "interrupted"];
@@ -7,7 +7,7 @@ export const PROTOCOL = "ino.v2";
7
7
  /** Every frame a client may send; the edge closes on any other (4009). */
8
8
  export const CLIENT_VERBS = ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed", "tool.result"];
9
9
  /** Every event the edge sends. */
10
- export const SERVER_EVENTS = ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
10
+ export const SERVER_EVENTS = ["ready", "turn.start", "heard", "heard.partial", "barge_in", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
11
11
  /** The events that end a turn: exactly one of them ends every turn. */
12
12
  export const TERMINAL_EVENTS = ["turn.done", "interrupted"];
13
13
  /** A `ping` of exactly this text is answered by the edge's runtime with `KEEPALIVE_PONG`, which has no `seq`. */
@@ -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,100 @@
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
+ /** The reply's loudness, 0 to 1: kept across `stop()`'s new gain node. */
54
+ private volume;
55
+ private readonly channels;
56
+ private readonly onIdle?;
57
+ constructor(options?: PcmPlayerOptions);
58
+ /**
59
+ * The rate of the PCM pushed from here on: each `audio.start` carries its
60
+ * window's own, since an agent's voice may be edited mid-conversation.
61
+ * What is queued plays on at its own rate — every buffer carries it — so
62
+ * nothing is dropped and the context stays.
63
+ */
64
+ setSampleRate(rate: number): void;
65
+ /**
66
+ * Create the context, or resume one the browser suspended.
67
+ *
68
+ * **Must be called from a user gesture the first time.** Every browser
69
+ * suspends a context created outside one, and a suspended context accepts
70
+ * every scheduling call silently — so the symptom is a conversation that
71
+ * looks like it is speaking and makes no sound.
72
+ */
73
+ resume(): Promise<void>;
74
+ /**
75
+ * Play quieter, or as before: hands-free lowers the reply while the
76
+ * person may be talking over it. A 15 ms glide, so it does not click.
77
+ */
78
+ setVolume(level: number): void;
79
+ /** Whether anything is scheduled and still to play. */
80
+ get playing(): boolean;
81
+ /**
82
+ * Queue one blob of PCM.
83
+ *
84
+ * Ignored before {@link resume} — a caller that pushes audio into a player
85
+ * nobody unlocked has a gesture problem, and buffering it would hide that
86
+ * until the sound arrived minutes later in one burst.
87
+ */
88
+ push(pcm: ArrayBuffer): void;
89
+ /**
90
+ * Stop everything now — barge-in, or the turn being cancelled.
91
+ *
92
+ * Disconnecting the gain node rather than stopping each source: sources are
93
+ * fire-and-forget once scheduled and holding references to them purely to
94
+ * stop them is a leak waiting to happen. A fresh node is built on the next
95
+ * push.
96
+ */
97
+ stop(): void;
98
+ /** Release the audio device. Safe to call more than once. */
99
+ close(): Promise<void>;
100
+ }
@@ -0,0 +1,201 @@
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 reply's loudness, 0 to 1: kept across `stop()`'s new gain node. */
54
+ this.volume = 1;
55
+ // The edge's default (the vocabulary's), until an `audio.start` says
56
+ // otherwise.
57
+ this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
58
+ this.channels = options.channels ?? 1;
59
+ this.onIdle = options.onIdle;
60
+ }
61
+ /**
62
+ * The rate of the PCM pushed from here on: each `audio.start` carries its
63
+ * window's own, since an agent's voice may be edited mid-conversation.
64
+ * What is queued plays on at its own rate — every buffer carries it — so
65
+ * nothing is dropped and the context stays.
66
+ */
67
+ setSampleRate(rate) {
68
+ if (!Number.isFinite(rate) || rate <= 0)
69
+ return;
70
+ this.sampleRate = rate;
71
+ }
72
+ /**
73
+ * Create the context, or resume one the browser suspended.
74
+ *
75
+ * **Must be called from a user gesture the first time.** Every browser
76
+ * suspends a context created outside one, and a suspended context accepts
77
+ * every scheduling call silently — so the symptom is a conversation that
78
+ * looks like it is speaking and makes no sound.
79
+ */
80
+ async resume() {
81
+ if (!this.context) {
82
+ this.context = new AudioContext();
83
+ this.gain = this.context.createGain();
84
+ this.gain.gain.value = this.volume;
85
+ this.gain.connect(this.context.destination);
86
+ }
87
+ if (this.context.state === "suspended")
88
+ await this.context.resume();
89
+ }
90
+ /**
91
+ * Play quieter, or as before: hands-free lowers the reply while the
92
+ * person may be talking over it. A 15 ms glide, so it does not click.
93
+ */
94
+ setVolume(level) {
95
+ this.volume = level;
96
+ if (this.context && this.gain) {
97
+ this.gain.gain.setTargetAtTime(level, this.context.currentTime, 0.015);
98
+ }
99
+ }
100
+ /** Whether anything is scheduled and still to play. */
101
+ get playing() {
102
+ return this.scheduled > 0;
103
+ }
104
+ /**
105
+ * Queue one blob of PCM.
106
+ *
107
+ * Ignored before {@link resume} — a caller that pushes audio into a player
108
+ * nobody unlocked has a gesture problem, and buffering it would hide that
109
+ * until the sound arrived minutes later in one burst.
110
+ */
111
+ push(pcm) {
112
+ const context = this.context;
113
+ if (!context)
114
+ return;
115
+ // **A split sample is carried, never dropped.** The edge sends whole
116
+ // samples per frame (`apps/edge/src/voice/tts.ts` holds an odd byte for
117
+ // the next blob), so this should never fire — but truncating instead is
118
+ // the bug that made ElevenLabs playback white noise, and it is worth being
119
+ // unable to reintroduce. Dropping the odd byte does not lose one sample,
120
+ // it shifts every byte after it by one and turns the rest of the frame
121
+ // into noise until something realigns it.
122
+ let bytes = new Uint8Array(pcm);
123
+ if (this.remainder.length) {
124
+ const joined = new Uint8Array(this.remainder.length + bytes.length);
125
+ joined.set(this.remainder, 0);
126
+ joined.set(bytes, this.remainder.length);
127
+ bytes = joined;
128
+ }
129
+ const usable = bytes.length - (bytes.length % 2);
130
+ this.remainder = bytes.slice(usable);
131
+ if (usable < 2)
132
+ return;
133
+ // Copied into its own buffer rather than viewed in place: `Int16Array`
134
+ // needs a 2-byte-aligned offset, and a frame joined to a carried byte has
135
+ // no such promise.
136
+ const samples = new Int16Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + usable));
137
+ // At the stream's rate, not the context's: the browser resamples it.
138
+ const buffer = context.createBuffer(this.channels, samples.length / this.channels, this.sampleRate);
139
+ for (let channel = 0; channel < this.channels; channel += 1) {
140
+ const target = buffer.getChannelData(channel);
141
+ for (let i = 0; i < target.length; i += 1)
142
+ target[i] = samples[i * this.channels + channel] / FULL_SCALE;
143
+ }
144
+ const source = context.createBufferSource();
145
+ source.buffer = buffer;
146
+ source.connect(this.gain);
147
+ // **The branch that keeps it gapless.** While audio is still queued, the
148
+ // next blob starts exactly where the last one ends; once the queue has run
149
+ // dry, the playhead is in the past and scheduling against it would mean
150
+ // "start before now", which the API resolves as "start immediately" for the
151
+ // first blob and drops for every one after it.
152
+ const now = context.currentTime;
153
+ const startAt = this.playhead > now ? this.playhead : now + SCHEDULE_AHEAD;
154
+ source.start(startAt);
155
+ this.playhead = startAt + buffer.duration;
156
+ this.scheduled += 1;
157
+ const generation = this.generation;
158
+ source.onended = () => {
159
+ if (generation !== this.generation)
160
+ return;
161
+ this.scheduled -= 1;
162
+ if (this.scheduled === 0)
163
+ this.onIdle?.();
164
+ };
165
+ }
166
+ /**
167
+ * Stop everything now — barge-in, or the turn being cancelled.
168
+ *
169
+ * Disconnecting the gain node rather than stopping each source: sources are
170
+ * fire-and-forget once scheduled and holding references to them purely to
171
+ * stop them is a leak waiting to happen. A fresh node is built on the next
172
+ * push.
173
+ */
174
+ stop() {
175
+ if (!this.context || !this.gain)
176
+ return;
177
+ this.gain.disconnect();
178
+ this.gain = this.context.createGain();
179
+ this.gain.gain.value = this.volume;
180
+ this.gain.connect(this.context.destination);
181
+ this.playhead = 0;
182
+ const wasPlaying = this.scheduled > 0;
183
+ this.scheduled = 0;
184
+ this.generation += 1;
185
+ // A half sample belonging to audio that will never play now.
186
+ this.remainder = new Uint8Array(0);
187
+ if (wasPlaying)
188
+ this.onIdle?.();
189
+ }
190
+ /** Release the audio device. Safe to call more than once. */
191
+ async close() {
192
+ const context = this.context;
193
+ this.context = null;
194
+ this.gain = null;
195
+ this.playhead = 0;
196
+ this.scheduled = 0;
197
+ this.remainder = new Uint8Array(0);
198
+ if (context && context.state !== "closed")
199
+ await context.close();
200
+ }
201
+ }
@@ -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
+ }