@lumavox/client 0.14.1 → 0.16.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
@@ -91,7 +91,7 @@ key, dials, authenticates, and from then on:
91
91
 
92
92
  | Handler | Frame |
93
93
  |---|---|
94
- | `onReady` `onTurnStart` `onHeard` `onHeardPartial` `onKnowledge` `onMood` `onThought` `onText` `onChunk` `onToolStart` `onToolEnd` `onToolConfirm` `onUsage` `onMuted` `onAudioStart` `onAudioEnd` `onTranscript` `onDone` `onInterrupted` `onError` | the wire's frame, as it is |
94
+ | `onReady` `onTurnStart` `onHeard` `onHeardPartial` `onKnowledge` `onMood` `onThought` `onText` `onChunk` `onToolStart` `onToolEnd` `onToolConfirm` `onUsage` `onMuted` `onAudioStart` `onAudioEnd` `onTranscript` `onCompacted` `onDone` `onInterrupted` `onError` | the wire's frame, as it is |
95
95
  | `onAudio` | `{ pcm, chunk, turn, sample_rate, channels }`: raw 16-bit PCM, never copied |
96
96
  | `onRelation` | the relationship, each change once |
97
97
  | `onState` | `connecting` `ready` `busy` `reconnecting` `dormant` `closed` |
@@ -143,12 +143,37 @@ startButton.onclick = () => audio.prime(); // in the click: browsers start sound
143
143
  ```
144
144
 
145
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
146
+ it (~8.5 KB minified): replies play gaplessly as they arrive, at the
147
147
  device's own rate; the microphone records at 24 kHz, with the browser's
148
148
  echo cancellation, noise suppression and gain control. Hand it to
149
149
  `@lumavox/react`'s `useAgent`, or call `play`, `startCapture` and the rest
150
150
  yourself.
151
151
 
152
+ **The clock**, to draw a face or subtitles against the voice:
153
+
154
+ ```ts
155
+ const audio = createWebAudio({ leadInMs: 350 }); // time to read each frame before it plays
156
+ audio.onScheduled(({ pcm, sample_rate, startsAt }) => lipsync.add(pcm, sample_rate, startsAt));
157
+ audio.onStopped(() => lipsync.clear()); // a cancel, a mute, a barge-in
158
+ // each animation frame: lipsync.at(audio.heardTime())
159
+ ```
160
+
161
+ - `onScheduled`: every frame as it is queued, its 16-bit samples and when
162
+ they start on `now()`'s clock. `heardTime()`: what is heard at this
163
+ moment, the clock less the browser's output latency (Bluetooth adds to
164
+ it).
165
+ - `leadInMs`: a reply's first frame, or one after a gap, waits that long —
166
+ 60 ms unless set; it adds as much to the first word.
167
+ - `onStopped`: a `stop()` that dropped audio still to play. `onCapture((pcm,
168
+ rate) => …)`: every frame the microphone captures, for a level meter.
169
+
170
+ **The devices**: `inputs()` and `setInput(id)` for the microphone (one
171
+ unplugged falls back to the default; labels are empty until it was
172
+ allowed), `setProcessing({ echoCancellation, noiseSuppression,
173
+ autoGainControl })` — each on unless `false`, and hands-free refused with
174
+ echo cancellation off — and, where `canChooseOutput` (Chrome and Edge),
175
+ `outputs()` and `setOutputDevice(id)` for the speaker.
176
+
152
177
  ### Hands-free
153
178
 
154
179
  ```ts
package/dist/audio.d.ts CHANGED
@@ -8,7 +8,11 @@
8
8
  * mute or a new turn, records at the rate the recorder was granted, and
9
9
  * throws away a recording the device interrupted. Hands-free — the
10
10
  * microphone left open, a detector saying when the person talks — is
11
- * `listen` (`./hands-free.ts`), on the two optional methods below.
11
+ * `listen` (`./hands-free.ts`), on the two optional methods below. The
12
+ * clock, the frames as they are queued and captured, and the devices
13
+ * (`PlaybackClock`, `DeviceChoices`) are the two adapters' own, for the
14
+ * app: nothing that drives an `AgentAudio` needs them, so an adapter of an
15
+ * app's own need not have them.
12
16
  */
13
17
  export interface AgentAudio {
14
18
  /** Queue one frame of a reply: 16-bit little-endian PCM, in wire order. */
@@ -62,3 +66,86 @@ export interface AgentAudio {
62
66
  /** Optional: play the reply quieter, or as before — barge-in's first step, on sound alone. */
63
67
  duck?(on: boolean): void;
64
68
  }
69
+ /** One frame of a reply as the player queued it: what a face or subtitles are drawn against. */
70
+ export interface ScheduledAudio {
71
+ /** The 16-bit little-endian samples queued: a sample split across two frames already joined. */
72
+ pcm: ArrayBuffer;
73
+ sample_rate: number;
74
+ channels: number;
75
+ /** As the socket sent it: the text chunk this is the voice of, in its turn. */
76
+ chunk: number;
77
+ turn: string;
78
+ /** When it starts to play, in seconds on `now()`'s clock. */
79
+ startsAt: number;
80
+ /** Seconds. */
81
+ duration: number;
82
+ }
83
+ /**
84
+ * The player's clock and what passes through it, for an app that draws
85
+ * against the voice — a face lip-synced, subtitles revealed chunk by chunk
86
+ * — or against the microphone, a level meter. `@lumavox/client/web-audio`'s
87
+ * and `@lumavox/react-native`'s audio have it; `useAgent` needs none of it.
88
+ */
89
+ export interface PlaybackClock {
90
+ /** The player's clock, in seconds: its audio context's. `0` before `prime()`. */
91
+ now(): number;
92
+ /**
93
+ * What the listener hears at this moment, on `now()`'s clock: `now()`
94
+ * less the time the sound takes to leave the device, which Bluetooth
95
+ * makes longer. Never negative.
96
+ */
97
+ heardTime(): number;
98
+ /**
99
+ * Every frame as it is queued, before it plays: its samples and when
100
+ * they start. A handler that throws is rethrown on its own. Answers the
101
+ * unsubscribe.
102
+ */
103
+ onScheduled(fn: (audio: ScheduledAudio) => void): () => void;
104
+ /**
105
+ * When `stop()` drops audio still to play — a cancel, a mute, a
106
+ * barge-in, a new turn. `onDrained` fires then too, and when a reply
107
+ * plays out. Answers the unsubscribe.
108
+ */
109
+ onStopped(fn: () => void): () => void;
110
+ /**
111
+ * Every frame the microphone captures while it is open — what
112
+ * `startCapture` hands on, and what hands-free does not send — as 16-bit
113
+ * PCM at `rate`. Answers the unsubscribe.
114
+ */
115
+ onCapture(fn: (pcm: ArrayBuffer, rate: number) => void): () => void;
116
+ }
117
+ /** A microphone or a speaker, as the browser lists it. */
118
+ export interface AudioDevice {
119
+ id: string;
120
+ /** Empty until the person has allowed the microphone. */
121
+ label: string;
122
+ }
123
+ /** The browser's own cleanup of the microphone: each on unless `false`. */
124
+ export interface Processing {
125
+ /** Off, hands-free is refused: the agent would hear itself. */
126
+ echoCancellation?: boolean;
127
+ noiseSuppression?: boolean;
128
+ autoGainControl?: boolean;
129
+ }
130
+ /**
131
+ * The microphone and the speaker a browser lets a page choose. On a phone
132
+ * the system chooses: no devices are listed and none can be set.
133
+ */
134
+ export interface DeviceChoices {
135
+ /** The microphones; labels are empty until the microphone was allowed. */
136
+ inputs(): Promise<AudioDevice[]>;
137
+ /** The microphone, by its id, from the next capture; none, the default. One unplugged since falls back to the default. */
138
+ setInput(id?: string): void;
139
+ /** The browser's processing from the next capture: what is left out is on. */
140
+ setProcessing(processing: Processing): void;
141
+ /** The speakers a page can play through; `[]` where it cannot choose. */
142
+ outputs(): Promise<AudioDevice[]>;
143
+ /**
144
+ * Play through the speaker `id`, kept for a player made later. Answers
145
+ * whether it took: `false` where the browser cannot choose, or the
146
+ * speaker is gone.
147
+ */
148
+ setOutputDevice(id: string): Promise<boolean>;
149
+ /** Whether this browser lets a page choose its speaker: Chrome and Edge. */
150
+ readonly canChooseOutput: boolean;
151
+ }
@@ -157,6 +157,8 @@ export interface ConversationHandlers {
157
157
  onInterrupted?: On<"interrupted">;
158
158
  /** The relationship, from `ready` and when it moves: each change once. */
159
159
  onRelation?: (relation: RelationInfo) => void;
160
+ /** The conversation was compacted, after a reply: its transcript is unchanged. */
161
+ onCompacted?: On<"compacted">;
160
162
  onError?: On<"error">;
161
163
  onState?: (state: ConversationState) => void;
162
164
  /** Only when it will **not** reconnect. */
@@ -33,6 +33,7 @@ const HANDLER = {
33
33
  "audio.start": "onAudioStart",
34
34
  "audio.end": "onAudioEnd",
35
35
  transcript: "onTranscript",
36
+ compacted: "onCompacted",
36
37
  "turn.done": "onDone",
37
38
  interrupted: "onInterrupted",
38
39
  error: "onError",
package/dist/events.d.ts CHANGED
@@ -182,6 +182,18 @@ export interface MutedFrame extends Frame<"muted">, InTurn {
182
182
  /** The relationship moved on this turn: its new fields. */
183
183
  export interface RelationFrame extends Frame<"relation">, InTurn, RelationInfo {
184
184
  }
185
+ /**
186
+ * The conversation was compacted — the agent's own past its threshold, a
187
+ * pass of a long one, or one asked for — after a reply, never in one: its
188
+ * next turn starts from a summary through `after_seq`, then the messages
189
+ * after it word for word. Not part of any turn.
190
+ */
191
+ export interface CompactedFrame extends Frame<"compacted"> {
192
+ session_id: string;
193
+ /** The last message the summary covers. */
194
+ after_seq: number;
195
+ trigger: "automatic" | "manual";
196
+ }
185
197
  export interface UsageFrame extends Frame<"usage">, InTurn {
186
198
  model: string;
187
199
  input_tokens: number;
@@ -282,6 +294,7 @@ export type ServerFrames = Exhaustive<{
282
294
  "audio.end": AudioEndFrame;
283
295
  muted: MutedFrame;
284
296
  relation: RelationFrame;
297
+ compacted: CompactedFrame;
285
298
  usage: UsageFrame;
286
299
  "turn.done": TurnDoneFrame;
287
300
  interrupted: InterruptedFrame;
@@ -270,7 +270,7 @@ export type SessionInfo = {
270
270
  };
271
271
  export type CompactionInfo = {
272
272
  /**
273
- * The last message this summary covers. Anything after it is still sent to the model verbatim.
273
+ * The last message this summary covers. Anything after it — the last few exchanges, kept word for word, then whatever is said next — is still sent to the model verbatim.
274
274
  */
275
275
  after_seq: number;
276
276
  /**
@@ -289,7 +289,7 @@ export type CompactionInfo = {
289
289
  */
290
290
  summary: string;
291
291
  /**
292
- * `manual` (a client asked), `automatic` (a turn reached the agent's threshold) or `idle` (the conversation went quiet and its facts were extracted).
292
+ * `manual` (a client asked) or `automatic` (a turn reached the agent's threshold, or a long compaction's next pass). `idle` is no longer written: it marks compactions made when a quiet conversation's facts were read, before facts were read on their own.
293
293
  */
294
294
  trigger: 'manual' | 'automatic' | 'idle';
295
295
  };
@@ -553,6 +553,10 @@ export type CompactSessionErrors = {
553
553
  * Missing or invalid credentials
554
554
  */
555
555
  401: ErrorResponse;
556
+ /**
557
+ * The key has spent its credit — mint another; nothing was charged
558
+ */
559
+ 402: ErrorResponse;
556
560
  /**
557
561
  * The key is valid but its scopes do not cover this capability
558
562
  */
@@ -566,7 +570,7 @@ export type CompactSessionErrors = {
566
570
  */
567
571
  409: ErrorResponse;
568
572
  /**
569
- * The request failed validation
573
+ * Nothing new to compact since the last compaction, or the model's summary was empty or no shorter than what it would replace; nothing was charged
570
574
  */
571
575
  422: ErrorResponse;
572
576
  /**
@@ -603,6 +607,10 @@ export type ExtractSessionFactsErrors = {
603
607
  * Missing or invalid credentials
604
608
  */
605
609
  401: ErrorResponse;
610
+ /**
611
+ * The key has spent its credit — mint another; nothing was charged
612
+ */
613
+ 402: ErrorResponse;
606
614
  /**
607
615
  * The key is valid but its scopes do not cover this capability
608
616
  */
@@ -616,7 +624,7 @@ export type ExtractSessionFactsErrors = {
616
624
  */
617
625
  409: ErrorResponse;
618
626
  /**
619
- * The request failed validation
627
+ * Nothing new to compact since the last compaction, or the model's summary was empty or no shorter than what it would replace; nothing was charged
620
628
  */
621
629
  422: ErrorResponse;
622
630
  /**
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * vocabulary (`src/protocol.gen.ts`); methods are camelCase, and the SDK's
8
8
  * own options go last.
9
9
  */
10
- export type { AgentAudio } from "./audio.js";
10
+ export type { AgentAudio, AudioDevice, DeviceChoices, PlaybackClock, Processing, ScheduledAudio, } from "./audio.js";
11
11
  export { type Client, type ClientOptions, createClient, DEFAULT_BASE_URL, type FactListParams, type Facts, type RelationListParams, type Relations, type SessionGetParams, type SessionListParams, type Sessions, } from "./client.js";
12
12
  export { type CloseInfo, type ConnectOptions, Conversation, type ConversationHandlers, type ConversationState, type Disconnect, type Dropped, READY_DEADLINE_MS, type Snapshot, type StartAudioOptions, type ToolHandler, type TranscriptOutcome, type TurnOptions, type TurnOutcome, type TurnSnapshot, } from "./conversation.js";
13
13
  export { AuthenticationError, ConflictError, ConnectionError, InsufficientCreditsError, NotFoundError, PermissionError, PlatformError, type PlatformErrorInit, RateLimitError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
@@ -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", "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"];
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", "compacted", "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", "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"];
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", "compacted", "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`. */
@@ -9,7 +9,7 @@
9
9
  * button.onclick = () => audio.prime(); // in the click: browsers start audio only from one
10
10
  * ```
11
11
  */
12
- import { WebAudio } from "./web-audio.js";
13
- export type { WebAudio };
12
+ import { WebAudio, type WebAudioOptions } from "./web-audio.js";
13
+ export type { WebAudio, WebAudioOptions };
14
14
  /** A player and a recorder for one conversation. Call `prime()` from the click that starts it. */
15
- export declare function createWebAudio(): WebAudio;
15
+ export declare function createWebAudio(options?: WebAudioOptions): WebAudio;
@@ -11,6 +11,6 @@
11
11
  */
12
12
  import { WebAudio } from "./web-audio.js";
13
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();
14
+ export function createWebAudio(options) {
15
+ return new WebAudio(options);
16
16
  }
@@ -18,7 +18,15 @@
18
18
  * in the past — otherwise it is scheduled to have already happened and is
19
19
  * simply dropped, which is heard as a missing sentence.
20
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.
21
+ * arriving and it being decoded, at the cost of a few milliseconds. An
22
+ * app that reads each frame before it plays — a lipsync model — sets a
23
+ * longer one (`leadIn`), and every reply's first word waits that long.
24
+ *
25
+ * **`push` answers when the frame starts**, on the context's clock, and the
26
+ * samples it queued: an app draws a face against them (`web-audio.ts`'s
27
+ * `onScheduled`). What is heard is that clock less the device's latency
28
+ * (`latency`), read live: Bluetooth, and a speaker chosen (`setSink`),
29
+ * change it.
22
30
  *
23
31
  * **The context runs at the device's own rate**; each buffer is made at the
24
32
  * stream's (24 kHz unless `audio.start` says otherwise) and the browser
@@ -37,6 +45,19 @@ export interface PcmPlayerOptions {
37
45
  channels?: number;
38
46
  /** Called when the queue drains — every scheduled source has finished. */
39
47
  onIdle?: () => void;
48
+ /** Seconds a run starts after it is pushed, once the queue ran dry: `SCHEDULE_AHEAD`. */
49
+ leadIn?: number;
50
+ /** The speaker the context plays through from its start (`setSink`). */
51
+ sinkId?: string;
52
+ }
53
+ /** A frame as `push` queued it. */
54
+ export interface Pushed {
55
+ /** The whole samples queued: a split one joined, a trailing byte held back. */
56
+ pcm: ArrayBuffer;
57
+ sample_rate: number;
58
+ /** On the context's clock, in seconds. */
59
+ startsAt: number;
60
+ duration: number;
40
61
  }
41
62
  export declare class PcmPlayer {
42
63
  private context;
@@ -54,7 +75,24 @@ export declare class PcmPlayer {
54
75
  private volume;
55
76
  private readonly channels;
56
77
  private readonly onIdle?;
78
+ private readonly leadIn;
79
+ private sinkId?;
57
80
  constructor(options?: PcmPlayerOptions);
81
+ /** The context's clock, in seconds; `0` before `resume`. */
82
+ get now(): number;
83
+ /**
84
+ * Seconds from a sample leaving the context to it being heard: the
85
+ * browser's own processing and the device's. Read each time, since a
86
+ * Bluetooth headset or a new speaker changes it; `0` where a browser does
87
+ * not say (Safari has no `outputLatency`).
88
+ */
89
+ get latency(): number;
90
+ /**
91
+ * Play through the speaker `id` from now on, and on a context made later.
92
+ * Answers whether it took: a speaker unplugged since it was listed, or
93
+ * one the page may not use, leaves the one before.
94
+ */
95
+ setSink(id: string): Promise<boolean>;
58
96
  /**
59
97
  * The rate of the PCM pushed from here on: each `audio.start` carries its
60
98
  * window's own, since an agent's voice may be edited mid-conversation.
@@ -79,22 +117,23 @@ export declare class PcmPlayer {
79
117
  /** Whether anything is scheduled and still to play. */
80
118
  get playing(): boolean;
81
119
  /**
82
- * Queue one blob of PCM.
120
+ * Queue one blob of PCM, and answer what was queued and when it starts —
121
+ * `null` when nothing was: a lone byte held for its other half.
83
122
  *
84
123
  * Ignored before {@link resume} — a caller that pushes audio into a player
85
124
  * nobody unlocked has a gesture problem, and buffering it would hide that
86
125
  * until the sound arrived minutes later in one burst.
87
126
  */
88
- push(pcm: ArrayBuffer): void;
127
+ push(pcm: ArrayBuffer): Pushed | null;
89
128
  /**
90
129
  * Stop everything now — barge-in, or the turn being cancelled.
91
130
  *
92
131
  * Disconnecting the gain node rather than stopping each source: sources are
93
132
  * fire-and-forget once scheduled and holding references to them purely to
94
133
  * stop them is a leak waiting to happen. A fresh node is built on the next
95
- * push.
134
+ * push. Answers whether audio still to play was dropped.
96
135
  */
97
- stop(): void;
136
+ stop(): boolean;
98
137
  /** Release the audio device. Safe to call more than once. */
99
138
  close(): Promise<void>;
100
139
  }
@@ -18,7 +18,15 @@
18
18
  * in the past — otherwise it is scheduled to have already happened and is
19
19
  * simply dropped, which is heard as a missing sentence.
20
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.
21
+ * arriving and it being decoded, at the cost of a few milliseconds. An
22
+ * app that reads each frame before it plays — a lipsync model — sets a
23
+ * longer one (`leadIn`), and every reply's first word waits that long.
24
+ *
25
+ * **`push` answers when the frame starts**, on the context's clock, and the
26
+ * samples it queued: an app draws a face against them (`web-audio.ts`'s
27
+ * `onScheduled`). What is heard is that clock less the device's latency
28
+ * (`latency`), read live: Bluetooth, and a speaker chosen (`setSink`),
29
+ * change it.
22
30
  *
23
31
  * **The context runs at the device's own rate**; each buffer is made at the
24
32
  * stream's (24 kHz unless `audio.start` says otherwise) and the browser
@@ -57,6 +65,40 @@ export class PcmPlayer {
57
65
  this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
58
66
  this.channels = options.channels ?? 1;
59
67
  this.onIdle = options.onIdle;
68
+ this.leadIn = options.leadIn ?? SCHEDULE_AHEAD;
69
+ this.sinkId = options.sinkId;
70
+ }
71
+ /** The context's clock, in seconds; `0` before `resume`. */
72
+ get now() {
73
+ return this.context?.currentTime ?? 0;
74
+ }
75
+ /**
76
+ * Seconds from a sample leaving the context to it being heard: the
77
+ * browser's own processing and the device's. Read each time, since a
78
+ * Bluetooth headset or a new speaker changes it; `0` where a browser does
79
+ * not say (Safari has no `outputLatency`).
80
+ */
81
+ get latency() {
82
+ const context = this.context;
83
+ return context
84
+ ? (context.outputLatency || 0) + (context.baseLatency || 0)
85
+ : 0;
86
+ }
87
+ /**
88
+ * Play through the speaker `id` from now on, and on a context made later.
89
+ * Answers whether it took: a speaker unplugged since it was listed, or
90
+ * one the page may not use, leaves the one before.
91
+ */
92
+ async setSink(id) {
93
+ try {
94
+ await this.context?.setSinkId(id);
95
+ }
96
+ catch {
97
+ // swallowed: the answer is `false`, and the speaker before plays on.
98
+ return false;
99
+ }
100
+ this.sinkId = id;
101
+ return true;
60
102
  }
61
103
  /**
62
104
  * The rate of the PCM pushed from here on: each `audio.start` carries its
@@ -83,6 +125,12 @@ export class PcmPlayer {
83
125
  this.gain = this.context.createGain();
84
126
  this.gain.gain.value = this.volume;
85
127
  this.gain.connect(this.context.destination);
128
+ // Not awaited: the click's `resume` below comes first.
129
+ if (this.sinkId) {
130
+ this.context.setSinkId(this.sinkId).catch(() => {
131
+ // swallowed: gone since it was chosen — the default speaker plays.
132
+ });
133
+ }
86
134
  }
87
135
  if (this.context.state === "suspended")
88
136
  await this.context.resume();
@@ -102,7 +150,8 @@ export class PcmPlayer {
102
150
  return this.scheduled > 0;
103
151
  }
104
152
  /**
105
- * Queue one blob of PCM.
153
+ * Queue one blob of PCM, and answer what was queued and when it starts —
154
+ * `null` when nothing was: a lone byte held for its other half.
106
155
  *
107
156
  * Ignored before {@link resume} — a caller that pushes audio into a player
108
157
  * nobody unlocked has a gesture problem, and buffering it would hide that
@@ -111,7 +160,7 @@ export class PcmPlayer {
111
160
  push(pcm) {
112
161
  const context = this.context;
113
162
  if (!context)
114
- return;
163
+ return null;
115
164
  // **A split sample is carried, never dropped.** The edge sends whole
116
165
  // samples per frame (`apps/edge/src/voice/tts.ts` holds an odd byte for
117
166
  // the next blob), so this should never fire — but truncating instead is
@@ -129,7 +178,7 @@ export class PcmPlayer {
129
178
  const usable = bytes.length - (bytes.length % 2);
130
179
  this.remainder = bytes.slice(usable);
131
180
  if (usable < 2)
132
- return;
181
+ return null;
133
182
  // Copied into its own buffer rather than viewed in place: `Int16Array`
134
183
  // needs a 2-byte-aligned offset, and a frame joined to a carried byte has
135
184
  // no such promise.
@@ -150,7 +199,7 @@ export class PcmPlayer {
150
199
  // "start before now", which the API resolves as "start immediately" for the
151
200
  // first blob and drops for every one after it.
152
201
  const now = context.currentTime;
153
- const startAt = this.playhead > now ? this.playhead : now + SCHEDULE_AHEAD;
202
+ const startAt = this.playhead > now ? this.playhead : now + this.leadIn;
154
203
  source.start(startAt);
155
204
  this.playhead = startAt + buffer.duration;
156
205
  this.scheduled += 1;
@@ -162,6 +211,12 @@ export class PcmPlayer {
162
211
  if (this.scheduled === 0)
163
212
  this.onIdle?.();
164
213
  };
214
+ return {
215
+ pcm: samples.buffer,
216
+ sample_rate: this.sampleRate,
217
+ startsAt: startAt,
218
+ duration: buffer.duration,
219
+ };
165
220
  }
166
221
  /**
167
222
  * Stop everything now — barge-in, or the turn being cancelled.
@@ -169,11 +224,11 @@ export class PcmPlayer {
169
224
  * Disconnecting the gain node rather than stopping each source: sources are
170
225
  * fire-and-forget once scheduled and holding references to them purely to
171
226
  * stop them is a leak waiting to happen. A fresh node is built on the next
172
- * push.
227
+ * push. Answers whether audio still to play was dropped.
173
228
  */
174
229
  stop() {
175
230
  if (!this.context || !this.gain)
176
- return;
231
+ return false;
177
232
  this.gain.disconnect();
178
233
  this.gain = this.context.createGain();
179
234
  this.gain.gain.value = this.volume;
@@ -186,6 +241,7 @@ export class PcmPlayer {
186
241
  this.remainder = new Uint8Array(0);
187
242
  if (wasPlaying)
188
243
  this.onIdle?.();
244
+ return wasPlaying;
189
245
  }
190
246
  /** Release the audio device. Safe to call more than once. */
191
247
  async close() {
@@ -30,6 +30,7 @@
30
30
  * Loaded on the first press (`web-audio.ts`), so a page that never records
31
31
  * never parses it.
32
32
  */
33
+ import type { Processing } from "../audio.js";
33
34
  /** The rate this records at, whatever is asked: the socket's default. */
34
35
  export declare const RECORD_SAMPLE_RATE = 24000;
35
36
  /** Samples per delivered chunk — about 85 ms at 24 kHz. Small enough that the
@@ -46,6 +47,10 @@ export interface PcmRecorderOptions {
46
47
  /** Called with each block of samples, already 16-bit little-endian. */
47
48
  onChunk: (pcm: ArrayBuffer) => void;
48
49
  sampleRate?: number;
50
+ /** The microphone's id (`enumerateDevices`); the system's default when unset or gone. */
51
+ input?: string;
52
+ /** The browser's cleanup: each on unless `false`. */
53
+ processing?: Processing;
49
54
  }
50
55
  /** Float samples to 16-bit signed little-endian, which is what the wire wants. */
51
56
  export declare function toPcm16(samples: Float32Array): ArrayBuffer;
@@ -72,6 +77,8 @@ export declare class PcmRecorder {
72
77
  private source;
73
78
  private readonly onChunk;
74
79
  private readonly sampleRate;
80
+ private readonly input?;
81
+ private readonly processing;
75
82
  /** Samples not yet delivered — see `FRAME_SAMPLES`. */
76
83
  private pending;
77
84
  private pendingSamples;
@@ -95,6 +95,8 @@ export class PcmRecorder {
95
95
  this.pendingSamples = 0;
96
96
  this.onChunk = options.onChunk;
97
97
  this.sampleRate = options.sampleRate ?? RECORD_SAMPLE_RATE;
98
+ this.input = options.input;
99
+ this.processing = options.processing ?? {};
98
100
  }
99
101
  /** True once `start()` has a live microphone and has not been stopped. */
100
102
  get recording() {
@@ -121,16 +123,30 @@ export class PcmRecorder {
121
123
  async start() {
122
124
  if (this.stream)
123
125
  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
- },
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. An
128
+ // app may turn a part off — a music app its noise suppression.
129
+ const { echoCancellation, noiseSuppression, autoGainControl } = this.processing;
130
+ const audio = {
131
+ echoCancellation: echoCancellation !== false,
132
+ noiseSuppression: noiseSuppression !== false,
133
+ autoGainControl: autoGainControl !== false,
134
+ channelCount: 1,
135
+ };
136
+ const ask = (deviceId) => navigator.mediaDevices.getUserMedia({
137
+ audio: deviceId ? { ...audio, deviceId: { exact: deviceId } } : audio,
133
138
  });
139
+ try {
140
+ // `exact`, so the microphone chosen is the one that records...
141
+ this.stream = await ask(this.input);
142
+ }
143
+ catch (failure) {
144
+ // ...and one unplugged since it was chosen records from the default,
145
+ // rather than failing the press.
146
+ if (!this.input || failure.name !== "OverconstrainedError")
147
+ throw failure;
148
+ this.stream = await ask();
149
+ }
134
150
  try {
135
151
  this.context = new AudioContext({ sampleRate: this.sampleRate });
136
152
  // Autoplay policy suspends a context created outside a gesture. Pressing
@@ -18,25 +18,69 @@
18
18
  * Edge and Firefox remove a tab's own sound from its microphone — Firefox
19
19
  * only with the context at the device's rate, which the player keeps.
20
20
  * Every browser on an iPhone or iPad is WebKit, whose canceller fails
21
- * three ways (WebKit bugs 218012, 311451, 326286): refused there.
21
+ * three ways (WebKit bugs 218012, 311451, 326286): refused there — and
22
+ * wherever the app turned echo cancellation off.
23
+ * - **The clock and the frames are the app's** (`PlaybackClock`): each
24
+ * frame told as it is queued, with when it starts; the stop; what the
25
+ * microphone captures. A handler that throws is rethrown on its own turn,
26
+ * never inside `play`, `stop` or a capture — a lipsync bug must not cut
27
+ * the voice.
28
+ * - **The microphone, its processing and the speaker are the app's to
29
+ * choose** (`DeviceChoices`): the speaker through
30
+ * `AudioContext.setSinkId`, Chrome's and Edge's alone. Whether echo is
31
+ * still cancelled with a speaker that is not the default is Chrome's,
32
+ * and unchecked (left-overs #222).
22
33
  */
23
- import type { AgentAudio } from "../audio.js";
34
+ import type { AgentAudio, AudioDevice, DeviceChoices, PlaybackClock, Processing, ScheduledAudio } from "../audio.js";
35
+ export interface WebAudioOptions {
36
+ /**
37
+ * Milliseconds a reply's first frame — or one after a gap — waits before
38
+ * it plays: 60 unless set. Longer gives an app time to read each frame
39
+ * before it is heard (`onScheduled`), and adds as much to every first word.
40
+ */
41
+ leadInMs?: number;
42
+ /** The microphone, by its `inputs()` id: the default unless set. */
43
+ input?: string;
44
+ /** The browser's cleanup of the microphone: each on unless `false`. */
45
+ processing?: Processing;
46
+ }
47
+ /** What `play` is given: the frame's rate, and what `onScheduled` passes on when it has it. */
48
+ type Frame = Parameters<AgentAudio["play"]>[1];
24
49
  /** Why hands-free is refused on an iPhone or iPad. */
25
50
  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
51
  /** Why it is refused in a browser that cannot cancel echo at all. */
27
52
  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 {
53
+ /** Why it is refused with echo cancellation turned off (`setProcessing`). */
54
+ export declare const ECHO_OFF = "Echo cancellation is turned off, so the agent would hear itself. Turn it on, or use push-to-talk.";
55
+ export declare class WebAudio implements AgentAudio, PlaybackClock, DeviceChoices {
29
56
  private player;
30
57
  private recorder;
31
58
  private onPcm;
32
59
  private readonly drained;
60
+ private readonly scheduled;
61
+ private readonly stopped;
62
+ private readonly captured;
63
+ private readonly leadIn?;
64
+ private input?;
65
+ private processing;
66
+ private sink?;
67
+ constructor(options?: WebAudioOptions);
33
68
  /** Make the player and unlock it. **Call it from the click**, before any `await`. */
34
69
  prime(): Promise<void>;
35
- play(pcm: ArrayBuffer, frame: {
36
- sample_rate: number;
37
- }): void;
70
+ play(pcm: ArrayBuffer, frame: Pick<Frame, "sample_rate"> & Partial<Frame>): void;
38
71
  stop(): void;
39
72
  onDrained(fn: () => void): () => void;
73
+ now(): number;
74
+ heardTime(): number;
75
+ onScheduled(fn: (audio: ScheduledAudio) => void): () => void;
76
+ onStopped(fn: () => void): () => void;
77
+ onCapture(fn: (pcm: ArrayBuffer, rate: number) => void): () => void;
78
+ inputs(): Promise<AudioDevice[]>;
79
+ setInput(id?: string): void;
80
+ setProcessing(processing: Processing): void;
81
+ outputs(): Promise<AudioDevice[]>;
82
+ setOutputDevice(id: string): Promise<boolean>;
83
+ get canChooseOutput(): boolean;
40
84
  /** At the recorder's own rate, 24 kHz, whatever is asked. */
41
85
  startCapture(onPcm: (pcm: ArrayBuffer) => void, _options?: {
42
86
  rate?: number;
@@ -54,3 +98,4 @@ export declare class WebAudio implements AgentAudio {
54
98
  /** The conversation is over: the player's context goes too. */
55
99
  dispose(): void;
56
100
  }
101
+ export {};
@@ -5,20 +5,28 @@ const DUCKED = 0.3;
5
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
6
  /** Why it is refused in a browser that cannot cancel echo at all. */
7
7
  export const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
8
+ /** Why it is refused with echo cancellation turned off (`setProcessing`). */
9
+ export const ECHO_OFF = "Echo cancellation is turned off, so the agent would hear itself. Turn it on, or use push-to-talk.";
8
10
  export class WebAudio {
9
- constructor() {
11
+ constructor(options = {}) {
10
12
  this.player = null;
11
13
  this.recorder = null;
12
14
  this.onPcm = null;
13
15
  this.drained = new Set();
16
+ this.scheduled = new Set();
17
+ this.stopped = new Set();
18
+ this.captured = new Set();
19
+ if (options.leadInMs !== undefined)
20
+ this.leadIn = options.leadInMs / 1000;
21
+ this.input = options.input;
22
+ this.processing = options.processing ?? {};
14
23
  }
15
24
  /** Make the player and unlock it. **Call it from the click**, before any `await`. */
16
25
  prime() {
17
26
  this.player ?? (this.player = new PcmPlayer({
18
- onIdle: () => {
19
- for (const fn of this.drained)
20
- fn();
21
- },
27
+ leadIn: this.leadIn,
28
+ sinkId: this.sink,
29
+ onIdle: () => tell(this.drained),
22
30
  }));
23
31
  return this.player.resume();
24
32
  }
@@ -27,21 +35,73 @@ export class WebAudio {
27
35
  void this.prime();
28
36
  const player = this.player;
29
37
  player.setSampleRate(frame.sample_rate);
30
- player.push(pcm);
38
+ const queued = player.push(pcm);
39
+ if (queued) {
40
+ tell(this.scheduled, {
41
+ ...queued,
42
+ channels: frame.channels ?? 1,
43
+ chunk: frame.chunk ?? 0,
44
+ turn: frame.turn ?? "",
45
+ });
46
+ }
31
47
  }
32
48
  stop() {
33
- this.player?.stop();
49
+ if (this.player?.stop())
50
+ tell(this.stopped);
34
51
  }
35
52
  onDrained(fn) {
36
- this.drained.add(fn);
37
- return () => {
38
- this.drained.delete(fn);
39
- };
53
+ return add(this.drained, fn);
54
+ }
55
+ now() {
56
+ return this.player?.now ?? 0;
57
+ }
58
+ heardTime() {
59
+ return Math.max(0, this.now() - (this.player?.latency ?? 0));
60
+ }
61
+ onScheduled(fn) {
62
+ return add(this.scheduled, fn);
63
+ }
64
+ onStopped(fn) {
65
+ return add(this.stopped, fn);
66
+ }
67
+ onCapture(fn) {
68
+ return add(this.captured, fn);
69
+ }
70
+ inputs() {
71
+ return devices("audioinput");
72
+ }
73
+ setInput(id) {
74
+ this.input = id;
75
+ }
76
+ setProcessing(processing) {
77
+ this.processing = processing;
78
+ }
79
+ async outputs() {
80
+ return this.canChooseOutput ? devices("audiooutput") : [];
81
+ }
82
+ async setOutputDevice(id) {
83
+ if (!this.canChooseOutput)
84
+ return false;
85
+ if (this.player && !(await this.player.setSink(id)))
86
+ return false;
87
+ this.sink = id;
88
+ return true;
89
+ }
90
+ get canChooseOutput() {
91
+ return (typeof AudioContext !== "undefined" &&
92
+ "setSinkId" in AudioContext.prototype);
40
93
  }
41
94
  /** At the recorder's own rate, 24 kHz, whatever is asked. */
42
95
  async startCapture(onPcm, _options) {
43
96
  const { PcmRecorder } = await import("./recorder.js");
44
- const mic = new PcmRecorder({ onChunk: (pcm) => this.onPcm?.(pcm) });
97
+ const mic = new PcmRecorder({
98
+ input: this.input,
99
+ processing: this.processing,
100
+ onChunk: (pcm) => {
101
+ this.onPcm?.(pcm);
102
+ tell(this.captured, pcm, mic.rate);
103
+ },
104
+ });
45
105
  this.onPcm = onPcm;
46
106
  this.recorder = mic;
47
107
  try {
@@ -59,16 +119,24 @@ export class WebAudio {
59
119
  const deliver = this.onPcm;
60
120
  this.recorder = null;
61
121
  this.onPcm = null;
62
- const tail = mic?.stop();
63
- if (tail)
64
- deliver?.(tail);
122
+ if (!mic)
123
+ return;
124
+ const rate = mic.rate;
125
+ const tail = mic.stop();
126
+ if (!tail)
127
+ return;
128
+ deliver?.(tail);
129
+ tell(this.captured, tail, rate);
65
130
  }
66
131
  duck(on) {
67
132
  this.player?.setVolume(on ? DUCKED : 1);
68
133
  }
69
134
  async handsFree() {
70
- const devices = typeof navigator === "undefined" ? undefined : navigator.mediaDevices;
71
- if (!devices?.getSupportedConstraints?.().echoCancellation) {
135
+ if (this.processing.echoCancellation === false) {
136
+ return { ok: false, reason: ECHO_OFF };
137
+ }
138
+ const media = mediaDevices();
139
+ if (!media?.getSupportedConstraints?.().echoCancellation) {
72
140
  return { ok: false, reason: NO_ECHO };
73
141
  }
74
142
  // iPadOS says it is a Mac: a Mac with a touch screen is an iPad.
@@ -83,3 +151,34 @@ export class WebAudio {
83
151
  this.player = null;
84
152
  }
85
153
  }
154
+ const mediaDevices = () => typeof navigator === "undefined" ? undefined : navigator.mediaDevices;
155
+ /** The browser's devices of `kind`; ids and labels empty until the microphone was allowed. */
156
+ async function devices(kind) {
157
+ const listed = (await mediaDevices()?.enumerateDevices?.()) ?? [];
158
+ return listed
159
+ .filter((device) => device.kind === kind)
160
+ .map(({ deviceId, label }) => ({ id: deviceId, label }));
161
+ }
162
+ function add(listeners, fn) {
163
+ listeners.add(fn);
164
+ return () => {
165
+ listeners.delete(fn);
166
+ };
167
+ }
168
+ /**
169
+ * Call each of the app's handlers. One that throws is rethrown on its own
170
+ * turn of the event loop, where the app's error reporting sees it — not
171
+ * here, where it would stop the player or the recorder halfway.
172
+ */
173
+ function tell(listeners, ...args) {
174
+ for (const fn of listeners) {
175
+ try {
176
+ fn(...args);
177
+ }
178
+ catch (error) {
179
+ setTimeout(() => {
180
+ throw error;
181
+ });
182
+ }
183
+ }
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumavox/client",
3
- "version": "0.14.1",
3
+ "version": "0.16.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",