@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.
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,44 @@ 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
+
152
+ ### Hands-free
153
+
154
+ ```ts
155
+ import { listen } from "@lumavox/client";
156
+
157
+ const outcome = await listen(conversation, audio); // { ok: true, listening } or { ok: false, reason }
158
+ ```
159
+
160
+ The microphone left open: a detector hears each utterance (300 ms before
161
+ it sent first), 700 ms of silence ends it as a turn — a wait added to every
162
+ reply — and nothing is sent between utterances. Talking over the agent
163
+ lowers its voice; its first words that are not the agent's own stop it
164
+ (`onBargeIn`). Refused where the agent would hear itself: the web audio
165
+ allows it where the browser cancels echo (not on an iPhone or iPad);
166
+ `assumeNoEcho` with headphones on. `loudness()` is the built-in detector,
167
+ `detector` your own.
168
+
134
169
  ## Runtimes
135
170
 
136
171
  Browsers, React Native (Hermes, ES2020 — and each binary frame reaches JS
package/dist/audio.d.ts CHANGED
@@ -1,12 +1,14 @@
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. Hands-free — the
10
+ * microphone left open, a detector saying when the person talks — is
11
+ * `listen` (`./hands-free.ts`), on the two optional methods below.
10
12
  */
11
13
  export interface AgentAudio {
12
14
  /** Queue one frame of a reply: 16-bit little-endian PCM, in wire order. */
@@ -35,4 +37,28 @@ export interface AgentAudio {
35
37
  stopCapture(): Promise<void>;
36
38
  /** Free everything: the conversation is over. */
37
39
  dispose(): void;
40
+ /**
41
+ * Optional: `fn(true)` when the device takes the audio away — a phone
42
+ * call, Siri, another app, the app leaving the screen — and `fn(false)`
43
+ * when it gives it back. The adapter suspends playback (a reply's frames
44
+ * keep queueing) and closes its recorder itself; whoever drives it throws
45
+ * away the recording that was open, its speaker cut off mid-sentence.
46
+ * Answers the unsubscribe.
47
+ */
48
+ onInterruption?(fn: (interrupted: boolean) => void): () => void;
49
+ /**
50
+ * Optional, for hands-free: whether the agent's own voice stays out of
51
+ * the microphone — echo cancellation, or headphones — and if not, why,
52
+ * in words an app can show. Without it, `listen` refuses unless the app
53
+ * says it knows (`assumeNoEcho`): an agent that hears itself answers
54
+ * itself.
55
+ */
56
+ handsFree?(): Promise<{
57
+ ok: true;
58
+ } | {
59
+ ok: false;
60
+ reason: string;
61
+ }>;
62
+ /** Optional: play the reply quieter, or as before — barge-in's first step, on sound alone. */
63
+ duck?(on: boolean): void;
38
64
  }
@@ -135,6 +135,8 @@ export interface ConversationHandlers {
135
135
  onTurnStart?: On<"turn.start">;
136
136
  onHeard?: On<"heard">;
137
137
  onHeardPartial?: On<"heard.partial">;
138
+ /** A `bargeIn` recording heard the person's own words: stop playing the reply. */
139
+ onBargeIn?: On<"barge_in">;
138
140
  onKnowledge?: On<"knowledge">;
139
141
  onMood?: On<"mood">;
140
142
  onThought?: On<"thought.delta">;
@@ -203,6 +205,13 @@ export interface StartAudioOptions extends TurnOptions {
203
205
  language?: string;
204
206
  /** A transcription model (`STT_MODELS`); the agent's own when unset. */
205
207
  model?: string;
208
+ /**
209
+ * Hands-free: a recording that may start over the agent's voice. Its
210
+ * first words that are not the agent's own are `onBargeIn`, and stop the
211
+ * running turn; the recording goes on. One with no words is
212
+ * `discardAudio()`'s.
213
+ */
214
+ bargeIn?: boolean;
206
215
  }
207
216
  /** What `client.connect` hands the conversation. */
208
217
  export interface Setup {
@@ -221,6 +230,7 @@ export declare class Conversation {
221
230
  private readonly speech;
222
231
  private snap;
223
232
  private listeners;
233
+ private watchers;
224
234
  private socket;
225
235
  /** Moves with every dial and every end: a callback from an older one is stale. */
226
236
  private generation;
@@ -294,6 +304,18 @@ export declare class Conversation {
294
304
  endAudio(options?: {
295
305
  transcribeOnly?: false;
296
306
  }): Promise<TurnOutcome>;
307
+ /**
308
+ * Throw the open recording away without a word back: nothing of it is
309
+ * transcribed, and a running turn goes on — hands-free's false start, a
310
+ * cough or the agent's own voice, heard as no words.
311
+ */
312
+ discardAudio(): void;
313
+ /**
314
+ * Every frame from now on, as `onEvent` sees them: for what is added
315
+ * after `connect`, such as hands-free (`listen`). Answers the
316
+ * unsubscribe.
317
+ */
318
+ onFrame(fn: (frame: ServerFrame) => void): () => void;
297
319
  /** Stop the turn in flight, and drop what is queued and the open recording. What was said is still billed and stored. */
298
320
  cancel(): void;
299
321
  /** Stop speaking, and let the turn finish: the rest of the reply is never spoken, nor billed. */
@@ -19,6 +19,7 @@ const HANDLER = {
19
19
  "turn.start": "onTurnStart",
20
20
  heard: "onHeard",
21
21
  "heard.partial": "onHeardPartial",
22
+ barge_in: "onBargeIn",
22
23
  knowledge: "onKnowledge",
23
24
  mood: "onMood",
24
25
  "thought.delta": "onThought",
@@ -76,6 +77,7 @@ export class Conversation {
76
77
  queued: 0,
77
78
  };
78
79
  this.listeners = new Set();
80
+ this.watchers = new Set();
79
81
  this.socket = null;
80
82
  /** Moves with every dial and every end: a callback from an older one is stale. */
81
83
  this.generation = 0;
@@ -179,6 +181,8 @@ export class Conversation {
179
181
  frame.language = language;
180
182
  if (model)
181
183
  frame.model = model;
184
+ if (options.bargeIn)
185
+ frame.barge_in = true;
182
186
  if (this.socket?.ready) {
183
187
  this.socket.send(frame);
184
188
  this.said(recording);
@@ -244,6 +248,35 @@ export class Conversation {
244
248
  this.socket?.send(frame);
245
249
  });
246
250
  }
251
+ /**
252
+ * Throw the open recording away without a word back: nothing of it is
253
+ * transcribed, and a running turn goes on — hands-free's false start, a
254
+ * cough or the agent's own voice, heard as no words.
255
+ */
256
+ discardAudio() {
257
+ const recording = this.open;
258
+ if (!recording)
259
+ return;
260
+ this.open = null;
261
+ recording.over = true;
262
+ recording.dropped = "cancelled";
263
+ this.emit({ caption: null });
264
+ // Held for `ready`, it never went out: dropped, nothing of it is sent.
265
+ if (!recording.held && recording.socket === this.generation) {
266
+ this.socket?.send({ type: "user_audio.end", discard: true });
267
+ }
268
+ }
269
+ /**
270
+ * Every frame from now on, as `onEvent` sees them: for what is added
271
+ * after `connect`, such as hands-free (`listen`). Answers the
272
+ * unsubscribe.
273
+ */
274
+ onFrame(fn) {
275
+ this.watchers.add(fn);
276
+ return () => {
277
+ this.watchers.delete(fn);
278
+ };
279
+ }
247
280
  /** Stop the turn in flight, and drop what is queued and the open recording. What was said is still billed and stored. */
248
281
  cancel() {
249
282
  for (const pending of this.queue.splice(0)) {
@@ -535,6 +568,8 @@ export class Conversation {
535
568
  call(this.setup.handlers[name], frame);
536
569
  }
537
570
  call(this.setup.handlers.onEvent, frame);
571
+ for (const watcher of this.watchers)
572
+ call(watcher, frame);
538
573
  if (frame.type === "ready" ||
539
574
  frame.type === "turn.done" ||
540
575
  frame.type === "interrupted" ||
package/dist/events.d.ts CHANGED
@@ -58,6 +58,18 @@ export interface HeardFrame extends Frame<"heard">, InTurn {
58
58
  export interface HeardPartialFrame extends Frame<"heard.partial"> {
59
59
  text: string;
60
60
  }
61
+ /**
62
+ * A `bargeIn` recording heard the person's own words — not the agent's
63
+ * voice leaking back: stop playing the reply. The running turn it stopped,
64
+ * if one still ran, ends with `interrupted`. Once per recording.
65
+ */
66
+ export interface BargeInFrame extends Frame<"barge_in"> {
67
+ /** What was heard, as `heard.partial` said it. */
68
+ text: string;
69
+ recording_id: string | null;
70
+ /** The turn it stopped; `null` when none still ran, the device playing on. */
71
+ turn: string | null;
72
+ }
61
73
  /** A `transcribeOnly` recording's words: part of no turn. */
62
74
  export interface TranscriptFrame extends Frame<"transcript"> {
63
75
  text: string;
@@ -252,6 +264,7 @@ export type ServerFrames = Exhaustive<{
252
264
  "turn.start": TurnStartFrame;
253
265
  heard: HeardFrame;
254
266
  "heard.partial": HeardPartialFrame;
267
+ barge_in: BargeInFrame;
255
268
  transcript: TranscriptFrame;
256
269
  mood: MoodFrame;
257
270
  "text.delta": TextDeltaFrame;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Hands-free (`docs/plans/react-native.md`, D3, D4, D15, D16): the
3
+ * microphone stays open, a detector says when the person talks, and each
4
+ * utterance is one recording on the socket, as push-to-talk's.
5
+ *
6
+ * - **Pre-roll**: what was heard just before the detector said "talking"
7
+ * is sent first, or the first syllable is lost (300 ms, OpenAI's).
8
+ * - **Silence ends an utterance**: 700 ms of it, **added to every
9
+ * hands-free reply** — the mode's one latency cost, the app's to set
10
+ * (`endAfterMs`). Ours, not the transcriber's: its own end of speech
11
+ * needs the microphone sent all along, the silence billed.
12
+ * - **Only speech leaves the device**: nothing is sent between utterances.
13
+ * - **Barge-in in two steps** (D15): talking while the agent's voice plays
14
+ * lowers it (`AgentAudio.duck`) and opens the recording; the edge says
15
+ * `barge_in` on its first words that are not the agent's own, and the
16
+ * voice stops. No words within `wordsWithinMs` and it was not the person:
17
+ * the recording is thrown away (`discardAudio`), the voice comes back up,
18
+ * and the detector must hear silence before it opens another — so the
19
+ * agent's own leftover voice, or a long cough, never loops.
20
+ * - **The detector's threshold rises while the agent speaks**: what echo
21
+ * cancellation leaves of its voice is louder than a quiet room.
22
+ * - **Refused where the agent would hear itself** (D16): an adapter that
23
+ * says so, or cannot say (`AgentAudio.handsFree`) unless the app knows.
24
+ *
25
+ * Time here is audio time, counted in 20 ms frames, never the clock's: a
26
+ * busy thread delivers late, and the frames still say how long the
27
+ * silence was.
28
+ */
29
+ import type { AgentAudio } from "./audio.js";
30
+ import type { Conversation, TurnOptions } from "./conversation.js";
31
+ import type { BargeInFrame } from "./events.js";
32
+ export type Voice = "talking" | "silent";
33
+ /**
34
+ * Whether a frame of the microphone is someone talking: the built-in
35
+ * `loudness()`, or an app's own (D4) — Silero through
36
+ * `onnxruntime-react-native`, say.
37
+ */
38
+ export interface Detector {
39
+ /**
40
+ * One 20 ms frame of 16-bit PCM at `rate`. `speaking`: the agent's
41
+ * voice is playing, so what echo cancellation leaves of it is about.
42
+ */
43
+ push(frame: Int16Array, rate: number, speaking: boolean): Voice;
44
+ }
45
+ export interface LoudnessOptions {
46
+ /** The quietest talking, dBFS: -45. */
47
+ minDb?: number;
48
+ /** How far over the room's own noise talking is, dB: 15. */
49
+ overNoiseDb?: number;
50
+ /** Added while the agent speaks, dB: 10. */
51
+ whileSpeakingDb?: number;
52
+ }
53
+ /**
54
+ * The built-in detector: loudness, not a model — so it never stops the
55
+ * agent on its own (D15). Talking is a frame louder than the room's noise
56
+ * by `overNoiseDb`, and never quieter than `minDb`. The noise is the
57
+ * quietest 100 ms of the last 3 s: the gaps between words keep it low
58
+ * while someone talks, and a fan that starts is learnt within 3 s.
59
+ */
60
+ export declare function loudness(options?: LoudnessOptions): Detector;
61
+ export interface ListenOptions extends TurnOptions {
62
+ /** The detector: `loudness()` by default. */
63
+ detector?: Detector;
64
+ /** Hz to ask the recorder for: 16,000. It must grant one of `RECORDING_RATES`. */
65
+ rate?: number;
66
+ /** A language hint for every utterance, 2 to 8 characters. */
67
+ language?: string;
68
+ /** A transcription model (`STT_MODELS`); the agent's own when unset. */
69
+ model?: string;
70
+ /** Silence that ends an utterance, ms: 700 — added to every reply. */
71
+ endAfterMs?: number;
72
+ /** Sent from before the talking was heard, ms: 300. */
73
+ preRollMs?: number;
74
+ /** Talking this long opens a recording, ms: 100 — a click or a tap is shorter. */
75
+ startAfterMs?: number;
76
+ /**
77
+ * Over the agent's voice: no words this long into the talking, and it
78
+ * was not the person, ms: 1,200 — the plan's ~0.7 s and the partial
79
+ * transcripts' lag behind speech (left-overs #101), until a device
80
+ * measures it.
81
+ */
82
+ wordsWithinMs?: number;
83
+ /** The app knows the agent's voice stays out of the microphone — headphones, say — though the adapter cannot tell. */
84
+ assumeNoEcho?: boolean;
85
+ /** `listening` for the person, `hearing` them, `paused` while the device has the audio, then `stopped`. */
86
+ onState?(state: ListeningState): void;
87
+ /** The person talked over the agent: its voice was stopped. */
88
+ onBargeIn?(frame: BargeInFrame): void;
89
+ }
90
+ export type ListeningState = "listening" | "hearing" | "paused" | "stopped";
91
+ export interface Listening {
92
+ readonly state: ListeningState;
93
+ /** Close the microphone; an utterance half heard is thrown away. */
94
+ stop(): Promise<void>;
95
+ }
96
+ export type ListenOutcome = {
97
+ ok: true;
98
+ listening: Listening;
99
+ } | {
100
+ ok: false;
101
+ reason: string;
102
+ };
103
+ /** Why `listen` refuses an adapter that cannot say whether the agent hears itself. */
104
+ export declare const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use push-to-talk, or pass `assumeNoEcho` if you know it does.";
105
+ /**
106
+ * Leave the microphone open on `conversation`: every utterance the
107
+ * detector hears becomes a turn, and talking over the agent stops it.
108
+ * Answers `ok: false` with the reason, in words an app can show, where the
109
+ * agent would hear itself or the microphone would not open — push-to-talk
110
+ * still works there.
111
+ */
112
+ export declare function listen(conversation: Conversation, audio: AgentAudio, options?: ListenOptions): Promise<ListenOutcome>;
@@ -0,0 +1,285 @@
1
+ import { LITTLE_ENDIAN } from "./pcm.js";
2
+ import { RECORDING_RATES } from "./protocol.gen.js";
3
+ /** One frame's length: every rate the socket takes is a whole number of samples in it. */
4
+ const FRAME_MS = 20;
5
+ /**
6
+ * The built-in detector: loudness, not a model — so it never stops the
7
+ * agent on its own (D15). Talking is a frame louder than the room's noise
8
+ * by `overNoiseDb`, and never quieter than `minDb`. The noise is the
9
+ * quietest 100 ms of the last 3 s: the gaps between words keep it low
10
+ * while someone talks, and a fan that starts is learnt within 3 s.
11
+ */
12
+ export function loudness(options = {}) {
13
+ const minDb = options.minDb ?? -45;
14
+ const over = options.overNoiseDb ?? 15;
15
+ const extra = options.whileSpeakingDb ?? 10;
16
+ const blocks = [];
17
+ let quietest = Number.POSITIVE_INFINITY;
18
+ let frames = 0;
19
+ return {
20
+ push(frame, _rate, speaking) {
21
+ let sum = 0;
22
+ for (let i = 0; i < frame.length; i++) {
23
+ const sample = frame[i];
24
+ sum += sample * sample;
25
+ }
26
+ const rms = Math.sqrt(sum / Math.max(1, frame.length)) / 32768;
27
+ const db = 20 * Math.log10(rms + 1e-9);
28
+ quietest = Math.min(quietest, db);
29
+ const noise = Math.min(quietest, ...blocks);
30
+ if (++frames === 5) {
31
+ blocks.push(quietest);
32
+ if (blocks.length > 30)
33
+ blocks.shift();
34
+ quietest = Number.POSITIVE_INFINITY;
35
+ frames = 0;
36
+ }
37
+ const threshold = Math.max(minDb, noise + over) + (speaking ? extra : 0);
38
+ return db > threshold ? "talking" : "silent";
39
+ },
40
+ };
41
+ }
42
+ /** Why `listen` refuses an adapter that cannot say whether the agent hears itself. */
43
+ export const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use push-to-talk, or pass `assumeNoEcho` if you know it does.";
44
+ /**
45
+ * Leave the microphone open on `conversation`: every utterance the
46
+ * detector hears becomes a turn, and talking over the agent stops it.
47
+ * Answers `ok: false` with the reason, in words an app can show, where the
48
+ * agent would hear itself or the microphone would not open — push-to-talk
49
+ * still works there.
50
+ */
51
+ export async function listen(conversation, audio, options = {}) {
52
+ if (!options.assumeNoEcho) {
53
+ const echo = audio.handsFree
54
+ ? await audio.handsFree()
55
+ : { ok: false, reason: NO_ECHO_ANSWER };
56
+ if (!echo.ok)
57
+ return echo;
58
+ }
59
+ const engine = new HandsFree(conversation, audio, options);
60
+ const opened = await engine.start();
61
+ return opened === null
62
+ ? { ok: true, listening: engine }
63
+ : { ok: false, reason: opened };
64
+ }
65
+ class HandsFree {
66
+ constructor(conversation, audio, options) {
67
+ this.state = "listening";
68
+ this.rate = 0;
69
+ this.frameSamples = 0;
70
+ this.carry = new Int16Array(0);
71
+ /** The last frames before an utterance: its pre-roll. */
72
+ this.ring = [];
73
+ this.talkMs = 0;
74
+ this.quietMs = 0;
75
+ /** After a false start over the agent: silence first, then another. */
76
+ this.waitForQuiet = false;
77
+ this.open = null;
78
+ /** The agent's voice is playing: from an `audio.start` until the adapter drains. */
79
+ this.playing = false;
80
+ this.playingTurn = null;
81
+ /** The reply a barge-in stopped: its late windows are not the agent speaking. */
82
+ this.stoppedTurn = null;
83
+ this.off = [];
84
+ this.conversation = conversation;
85
+ this.audio = audio;
86
+ this.options = options;
87
+ this.detector = options.detector ?? loudness();
88
+ this.endAfterMs = options.endAfterMs ?? 700;
89
+ this.startAfterMs = options.startAfterMs ?? 100;
90
+ this.wordsWithinMs = options.wordsWithinMs ?? 1200;
91
+ this.ringFrames = Math.ceil(((options.preRollMs ?? 300) + this.startAfterMs) / FRAME_MS);
92
+ }
93
+ /** The microphone, and everything watched: `null`, or why it did not open. */
94
+ async start() {
95
+ this.off.push(this.conversation.onFrame((frame) => this.frame(frame)), this.audio.onDrained(() => {
96
+ this.playing = false;
97
+ }));
98
+ const interrupted = this.audio.onInterruption?.((now) => this.interrupted(now));
99
+ if (interrupted)
100
+ this.off.push(interrupted);
101
+ const failed = await this.capture();
102
+ if (failed !== null)
103
+ this.close();
104
+ return failed;
105
+ }
106
+ async stop() {
107
+ if (this.state === "stopped")
108
+ return;
109
+ this.close();
110
+ await this.audio.stopCapture();
111
+ }
112
+ close() {
113
+ for (const off of this.off.splice(0))
114
+ off();
115
+ this.set("stopped");
116
+ if (this.open)
117
+ this.throwAway();
118
+ }
119
+ async capture() {
120
+ let granted;
121
+ try {
122
+ ({ rate: granted } = await this.audio.startCapture((pcm) => this.pcm(pcm), { rate: this.options.rate ?? 16000 }));
123
+ }
124
+ catch (failure) {
125
+ // swallowed: it is the answer's reason, the app's to show.
126
+ return `Could not open the microphone: ${failure instanceof Error ? failure.message : String(failure)}`;
127
+ }
128
+ if (!RECORDING_RATES.includes(granted)) {
129
+ await this.audio.stopCapture();
130
+ return `The microphone opened at ${granted} Hz, which cannot be transcribed: ${RECORDING_RATES.join(", ")}.`;
131
+ }
132
+ this.rate = granted;
133
+ this.frameSamples = (granted * FRAME_MS) / 1000;
134
+ return null;
135
+ }
136
+ frame(frame) {
137
+ if (frame.type === "audio.start" && frame.turn !== this.stoppedTurn) {
138
+ this.playing = true;
139
+ this.playingTurn = frame.turn;
140
+ }
141
+ else if (frame.type === "barge_in") {
142
+ this.bargeIn(frame);
143
+ }
144
+ }
145
+ /** The person's own words over the agent: its voice stops, and comes back up for the next reply. */
146
+ bargeIn(frame) {
147
+ const open = this.open;
148
+ if (!open || open.barged || frame.recording_id !== open.id)
149
+ return;
150
+ open.barged = true;
151
+ this.stoppedTurn = frame.turn ?? this.playingTurn;
152
+ this.playing = false;
153
+ this.audio.stop();
154
+ this.audio.duck?.(false);
155
+ this.options.onBargeIn?.(frame);
156
+ }
157
+ /** The device took the audio, and closed the recorder; or gave it back. */
158
+ interrupted(now) {
159
+ if (this.state === "stopped")
160
+ return;
161
+ if (now) {
162
+ if (this.open)
163
+ this.throwAway();
164
+ this.set("paused");
165
+ return;
166
+ }
167
+ void this.capture().then((failed) => {
168
+ if (this.state !== "paused")
169
+ return;
170
+ // Still taken, or gone: stopped, and the app's next `listen` tries again.
171
+ if (failed === null)
172
+ this.set("listening");
173
+ else
174
+ this.close();
175
+ });
176
+ }
177
+ pcm(buffer) {
178
+ if (this.state !== "listening" && this.state !== "hearing")
179
+ return;
180
+ const fresh = samplesOf(buffer);
181
+ let samples = fresh;
182
+ if (this.carry.length) {
183
+ samples = new Int16Array(this.carry.length + fresh.length);
184
+ samples.set(this.carry, 0);
185
+ samples.set(fresh, this.carry.length);
186
+ }
187
+ let at = 0;
188
+ for (; at + this.frameSamples <= samples.length; at += this.frameSamples) {
189
+ this.step(samples.slice(at, at + this.frameSamples));
190
+ }
191
+ this.carry = samples.slice(at);
192
+ }
193
+ step(frame) {
194
+ const voice = this.detector.push(frame, this.rate, this.playing);
195
+ const talking = voice === "talking";
196
+ if (talking) {
197
+ this.talkMs += FRAME_MS;
198
+ this.quietMs = 0;
199
+ }
200
+ else {
201
+ this.talkMs = 0;
202
+ this.quietMs += FRAME_MS;
203
+ }
204
+ const open = this.open;
205
+ if (!open) {
206
+ this.ring.push(frame);
207
+ if (this.ring.length > this.ringFrames)
208
+ this.ring.shift();
209
+ if (this.waitForQuiet && this.quietMs >= this.endAfterMs) {
210
+ this.waitForQuiet = false;
211
+ }
212
+ if (!this.waitForQuiet && this.talkMs >= this.startAfterMs)
213
+ this.begin();
214
+ return;
215
+ }
216
+ this.conversation.sendAudio(frame);
217
+ open.ms += FRAME_MS;
218
+ if (open.barging && !open.barged && open.ms >= this.wordsWithinMs) {
219
+ this.throwAway();
220
+ this.waitForQuiet = true;
221
+ }
222
+ else if (this.quietMs >= this.endAfterMs) {
223
+ this.end();
224
+ }
225
+ }
226
+ begin() {
227
+ const barging = this.playing;
228
+ const options = this.options;
229
+ const id = this.conversation.startAudio({
230
+ rate: this.rate,
231
+ bargeIn: true,
232
+ ...(options.language ? { language: options.language } : {}),
233
+ ...(options.model ? { model: options.model } : {}),
234
+ ...(options.speech === undefined ? {} : { speech: options.speech }),
235
+ ...(options.thoughts === undefined ? {} : { thoughts: options.thoughts }),
236
+ });
237
+ this.open = { id, barging, barged: false, ms: this.talkMs };
238
+ if (barging)
239
+ this.audio.duck?.(true);
240
+ for (const frame of this.ring.splice(0))
241
+ this.conversation.sendAudio(frame);
242
+ this.set("hearing");
243
+ }
244
+ /** Silence: a turn — but over the agent, only once the person's own words were heard. */
245
+ end() {
246
+ const open = this.open;
247
+ if (open.barging && !open.barged) {
248
+ this.throwAway();
249
+ return;
250
+ }
251
+ this.open = null;
252
+ if (open.barging)
253
+ this.audio.duck?.(false);
254
+ // Answered on the conversation's handlers; it never rejects.
255
+ void this.conversation.endAudio();
256
+ this.set("listening");
257
+ }
258
+ /** Not the person: thrown away, and the agent's voice back up. */
259
+ throwAway() {
260
+ const open = this.open;
261
+ this.open = null;
262
+ this.conversation.discardAudio();
263
+ if (open.barging && !open.barged)
264
+ this.audio.duck?.(false);
265
+ if (this.state === "hearing")
266
+ this.set("listening");
267
+ }
268
+ set(state) {
269
+ if (this.state === state)
270
+ return;
271
+ this.state = state;
272
+ this.options.onState?.(state);
273
+ }
274
+ }
275
+ /** 16-bit little-endian PCM as samples, on a big-endian device too. */
276
+ function samplesOf(buffer) {
277
+ const count = buffer.byteLength >> 1;
278
+ if (LITTLE_ENDIAN)
279
+ return new Int16Array(buffer.slice(0, count * 2));
280
+ const view = new DataView(buffer);
281
+ const out = new Int16Array(count);
282
+ for (let i = 0; i < count; i++)
283
+ out[i] = view.getInt16(i * 2, true);
284
+ return out;
285
+ }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { type CloseInfo, type ConnectOptions, Conversation, type ConversationHan
13
13
  export { AuthenticationError, ConflictError, ConnectionError, InsufficientCreditsError, NotFoundError, PermissionError, PlatformError, type PlatformErrorInit, RateLimitError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
14
14
  export type * from "./events.js";
15
15
  export type * from "./gen/index.js";
16
+ export { type Detector, type Listening, type ListeningState, type ListenOptions, type ListenOutcome, type LoudnessOptions, listen, loudness, NO_ECHO_ANSWER, type Voice, } from "./hands-free.js";
16
17
  export { concatPcm, floatToPcm16, LITTLE_ENDIAN, pcm16ToFloat, pcmSeconds, resample, } from "./pcm.js";
17
18
  export * from "./protocol.gen.js";
18
19
  export { type AudioFrame, closeName, isFinalClose, MAX_RECONNECTS, type Reconnect, reconnectAfter, type Warning, type WebSocketConstructor, type WebSocketLike, } from "./socket.js";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  export { createClient, DEFAULT_BASE_URL, } from "./client.js";
11
11
  export { Conversation, READY_DEADLINE_MS, } from "./conversation.js";
12
12
  export { AuthenticationError, ConflictError, ConnectionError, InsufficientCreditsError, NotFoundError, PermissionError, PlatformError, RateLimitError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
13
+ export { listen, loudness, NO_ECHO_ANSWER, } from "./hands-free.js";
13
14
  export { concatPcm, floatToPcm16, LITTLE_ENDIAN, pcm16ToFloat, pcmSeconds, resample, } from "./pcm.js";
14
15
  export * from "./protocol.gen.js";
15
16
  export { closeName, isFinalClose, MAX_RECONNECTS, reconnectAfter, } from "./socket.js";