@lumavox/client 0.15.1 → 0.17.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 +33 -1
- package/dist/audio.d.ts +94 -1
- package/dist/hands-free.d.ts +46 -6
- package/dist/hands-free.js +147 -19
- package/dist/index.d.ts +1 -1
- package/dist/web-audio/index.d.ts +3 -3
- package/dist/web-audio/index.js +2 -2
- package/dist/web-audio/player.d.ts +44 -5
- package/dist/web-audio/player.js +63 -7
- package/dist/web-audio/recorder.d.ts +7 -0
- package/dist/web-audio/recorder.js +25 -9
- package/dist/web-audio/web-audio.d.ts +54 -8
- package/dist/web-audio/web-audio.js +118 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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 (~
|
|
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
|
|
@@ -166,6 +191,13 @@ allows it where the browser cancels echo (not on an iPhone or iPad);
|
|
|
166
191
|
`assumeNoEcho` with headphones on. `loudness()` is the built-in detector,
|
|
167
192
|
`detector` your own.
|
|
168
193
|
|
|
194
|
+
**Half-duplex**, where echo cannot be cancelled: `listen(conversation,
|
|
195
|
+
audio, { halfDuplex: true })` keeps the microphone open but sends nothing
|
|
196
|
+
while the agent's voice is heard — on the player's clock, then 300 ms more
|
|
197
|
+
(`afterVoiceMs`) for the room's echo — and talking never stops it, so the
|
|
198
|
+
agent never hears itself and it is never refused for echo. Something said
|
|
199
|
+
as the voice starts ends there as a turn, answered after the reply.
|
|
200
|
+
|
|
169
201
|
## Runtimes
|
|
170
202
|
|
|
171
203
|
Browsers, React Native (Hermes, ES2020 — and each binary frame reaches JS
|
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. */
|
|
@@ -30,6 +34,12 @@ export interface AgentAudio {
|
|
|
30
34
|
*/
|
|
31
35
|
startCapture(onPcm: (pcm: ArrayBuffer) => void, options?: {
|
|
32
36
|
rate?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Hands-free asks it, once `handsFree` said yes: take the agent's
|
|
39
|
+
* voice out of the microphone — a phone's voice processing and call
|
|
40
|
+
* audio. An adapter that always does, as a browser's, ignores it.
|
|
41
|
+
*/
|
|
42
|
+
echoCancellation?: boolean;
|
|
33
43
|
}): Promise<{
|
|
34
44
|
rate: number;
|
|
35
45
|
}>;
|
|
@@ -62,3 +72,86 @@ export interface AgentAudio {
|
|
|
62
72
|
/** Optional: play the reply quieter, or as before — barge-in's first step, on sound alone. */
|
|
63
73
|
duck?(on: boolean): void;
|
|
64
74
|
}
|
|
75
|
+
/** One frame of a reply as the player queued it: what a face or subtitles are drawn against. */
|
|
76
|
+
export interface ScheduledAudio {
|
|
77
|
+
/** The 16-bit little-endian samples queued: a sample split across two frames already joined. */
|
|
78
|
+
pcm: ArrayBuffer;
|
|
79
|
+
sample_rate: number;
|
|
80
|
+
channels: number;
|
|
81
|
+
/** As the socket sent it: the text chunk this is the voice of, in its turn. */
|
|
82
|
+
chunk: number;
|
|
83
|
+
turn: string;
|
|
84
|
+
/** When it starts to play, in seconds on `now()`'s clock. */
|
|
85
|
+
startsAt: number;
|
|
86
|
+
/** Seconds. */
|
|
87
|
+
duration: number;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The player's clock and what passes through it, for an app that draws
|
|
91
|
+
* against the voice — a face lip-synced, subtitles revealed chunk by chunk
|
|
92
|
+
* — or against the microphone, a level meter. `@lumavox/client/web-audio`'s
|
|
93
|
+
* and `@lumavox/react-native`'s audio have it; `useAgent` needs none of it.
|
|
94
|
+
*/
|
|
95
|
+
export interface PlaybackClock {
|
|
96
|
+
/** The player's clock, in seconds: its audio context's. `0` before `prime()`. */
|
|
97
|
+
now(): number;
|
|
98
|
+
/**
|
|
99
|
+
* What the listener hears at this moment, on `now()`'s clock: `now()`
|
|
100
|
+
* less the time the sound takes to leave the device, which Bluetooth
|
|
101
|
+
* makes longer. Never negative.
|
|
102
|
+
*/
|
|
103
|
+
heardTime(): number;
|
|
104
|
+
/**
|
|
105
|
+
* Every frame as it is queued, before it plays: its samples and when
|
|
106
|
+
* they start. A handler that throws is rethrown on its own. Answers the
|
|
107
|
+
* unsubscribe.
|
|
108
|
+
*/
|
|
109
|
+
onScheduled(fn: (audio: ScheduledAudio) => void): () => void;
|
|
110
|
+
/**
|
|
111
|
+
* When `stop()` drops audio still to play — a cancel, a mute, a
|
|
112
|
+
* barge-in, a new turn. `onDrained` fires then too, and when a reply
|
|
113
|
+
* plays out. Answers the unsubscribe.
|
|
114
|
+
*/
|
|
115
|
+
onStopped(fn: () => void): () => void;
|
|
116
|
+
/**
|
|
117
|
+
* Every frame the microphone captures while it is open — what
|
|
118
|
+
* `startCapture` hands on, and what hands-free does not send — as 16-bit
|
|
119
|
+
* PCM at `rate`. Answers the unsubscribe.
|
|
120
|
+
*/
|
|
121
|
+
onCapture(fn: (pcm: ArrayBuffer, rate: number) => void): () => void;
|
|
122
|
+
}
|
|
123
|
+
/** A microphone or a speaker, as the browser lists it. */
|
|
124
|
+
export interface AudioDevice {
|
|
125
|
+
id: string;
|
|
126
|
+
/** Empty until the person has allowed the microphone. */
|
|
127
|
+
label: string;
|
|
128
|
+
}
|
|
129
|
+
/** The browser's own cleanup of the microphone: each on unless `false`. */
|
|
130
|
+
export interface Processing {
|
|
131
|
+
/** Off, hands-free is refused: the agent would hear itself. */
|
|
132
|
+
echoCancellation?: boolean;
|
|
133
|
+
noiseSuppression?: boolean;
|
|
134
|
+
autoGainControl?: boolean;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The microphone and the speaker a browser lets a page choose. On a phone
|
|
138
|
+
* the system chooses: no devices are listed and none can be set.
|
|
139
|
+
*/
|
|
140
|
+
export interface DeviceChoices {
|
|
141
|
+
/** The microphones; labels are empty until the microphone was allowed. */
|
|
142
|
+
inputs(): Promise<AudioDevice[]>;
|
|
143
|
+
/** The microphone, by its id, from the next capture; none, the default. One unplugged since falls back to the default. */
|
|
144
|
+
setInput(id?: string): void;
|
|
145
|
+
/** The browser's processing from the next capture: what is left out is on. */
|
|
146
|
+
setProcessing(processing: Processing): void;
|
|
147
|
+
/** The speakers a page can play through; `[]` where it cannot choose. */
|
|
148
|
+
outputs(): Promise<AudioDevice[]>;
|
|
149
|
+
/**
|
|
150
|
+
* Play through the speaker `id`, kept for a player made later. Answers
|
|
151
|
+
* whether it took: `false` where the browser cannot choose, or the
|
|
152
|
+
* speaker is gone.
|
|
153
|
+
*/
|
|
154
|
+
setOutputDevice(id: string): Promise<boolean>;
|
|
155
|
+
/** Whether this browser lets a page choose its speaker: Chrome and Edge. */
|
|
156
|
+
readonly canChooseOutput: boolean;
|
|
157
|
+
}
|
package/dist/hands-free.d.ts
CHANGED
|
@@ -25,10 +25,36 @@
|
|
|
25
25
|
* cancellation leaves of its voice is louder than a quiet room.
|
|
26
26
|
* - **Refused where the agent would hear itself** (D16): an adapter that
|
|
27
27
|
* says so, or cannot say (`AgentAudio.handsFree`) unless the app knows.
|
|
28
|
+
* On its yes the recorder is asked to cancel the echo
|
|
29
|
+
* (`startCapture`'s `echoCancellation`): a phone's is a mode of its own —
|
|
30
|
+
* the call's audio, quieter — never wanted with headphones on or in
|
|
31
|
+
* half-duplex.
|
|
32
|
+
* - **Half-duplex** (D19, `halfDuplex`), where there is no echo to cancel —
|
|
33
|
+
* a phone on `react-native-audio-api` 0.13, a browser that cannot cancel
|
|
34
|
+
* it: the microphone stays open, but **while the agent's voice is heard
|
|
35
|
+
* nothing is sent, the detector rests and there is no barge-in**, so the
|
|
36
|
+
* agent never hears itself and `handsFree()` is not asked. Heard is the
|
|
37
|
+
* player's clock (`PlaybackClock`: each frame's `startsAt` and
|
|
38
|
+
* `duration`, against `heardTime()`), never when frames are queued: a
|
|
39
|
+
* phone queues a reply seconds ahead of its sound. An adapter with no
|
|
40
|
+
* clock is taken at its word, from an `audio.start` to `onDrained`.
|
|
41
|
+
* **The voice has the floor**: an utterance open when it starts ends
|
|
42
|
+
* there as a turn — what was said before it is kept, and answered after
|
|
43
|
+
* the reply. **Then `afterVoiceMs`, 300 ms, before the detector listens
|
|
44
|
+
* again**: what the clock cannot know of a phone's sound path (0.13 says
|
|
45
|
+
* none; most phones' loudspeakers take 20 to 100 ms), the microphone's own
|
|
46
|
+
* latency (10 to 50 ms), and the room's echo falling 30 dB (half of a
|
|
47
|
+
* furnished room's 0.3 to 0.6 s reverberation) — else the agent's last
|
|
48
|
+
* syllable, ringing on, opens a recording of itself. It costs no reply
|
|
49
|
+
* any time: those frames are kept as pre-roll, so words begun in it keep
|
|
50
|
+
* their start; only a word over before it ends — a quick "yes" under
|
|
51
|
+
* 0.3 s — goes unheard.
|
|
28
52
|
*
|
|
29
53
|
* Time here is audio time, counted in 20 ms frames, never the clock's: a
|
|
30
54
|
* busy thread delivers late, and the frames still say how long the
|
|
31
|
-
* silence was.
|
|
55
|
+
* silence was. Half-duplex alone reads the player's clock, to set each
|
|
56
|
+
* frame against the voice: the last sample delivered is heard now, and
|
|
57
|
+
* each before it a sample earlier.
|
|
32
58
|
*/
|
|
33
59
|
import type { AgentAudio } from "./audio.js";
|
|
34
60
|
import type { Conversation, TurnOptions } from "./conversation.js";
|
|
@@ -88,6 +114,19 @@ export interface ListenOptions extends TurnOptions {
|
|
|
88
114
|
wordsWithinMs?: number;
|
|
89
115
|
/** The app knows the agent's voice stays out of the microphone — headphones, say — though the adapter cannot tell. */
|
|
90
116
|
assumeNoEcho?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Half-duplex: the microphone stays open, but nothing is sent while the
|
|
119
|
+
* agent's voice is heard, and talking never stops it — for where echo
|
|
120
|
+
* cannot be cancelled, such as a phone, so `handsFree()` is not asked.
|
|
121
|
+
*/
|
|
122
|
+
halfDuplex?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Half-duplex: after the agent's voice is heard ending, how long the
|
|
125
|
+
* detector still waits, ms: 300 — so the room's echo of its last word
|
|
126
|
+
* does not open a recording. Words begun in it keep their start; one
|
|
127
|
+
* over before it ends is not heard. Lower with headphones on.
|
|
128
|
+
*/
|
|
129
|
+
afterVoiceMs?: number;
|
|
91
130
|
/** `listening` for the person, `hearing` them, `paused` while the device has the audio, then `stopped`. */
|
|
92
131
|
onState?(state: ListeningState): void;
|
|
93
132
|
/** The person talked over the agent: its voice was stopped. */
|
|
@@ -107,12 +146,13 @@ export type ListenOutcome = {
|
|
|
107
146
|
reason: string;
|
|
108
147
|
};
|
|
109
148
|
/** Why `listen` refuses an adapter that cannot say whether the agent hears itself. */
|
|
110
|
-
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.";
|
|
149
|
+
export declare const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use half-duplex (`halfDuplex`) or push-to-talk, or pass `assumeNoEcho` if you know it does.";
|
|
111
150
|
/**
|
|
112
151
|
* Leave the microphone open on `conversation`: every utterance the
|
|
113
|
-
* detector hears becomes a turn, and talking over the agent stops it
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
152
|
+
* detector hears becomes a turn, and talking over the agent stops it — or,
|
|
153
|
+
* with `halfDuplex`, nothing is heard while the agent speaks. Answers
|
|
154
|
+
* `ok: false` with the reason, in words an app can show, where the agent
|
|
155
|
+
* would hear itself or the microphone would not open — half-duplex and
|
|
156
|
+
* push-to-talk still work there.
|
|
117
157
|
*/
|
|
118
158
|
export declare function listen(conversation: Conversation, audio: AgentAudio, options?: ListenOptions): Promise<ListenOutcome>;
|
package/dist/hands-free.js
CHANGED
|
@@ -40,16 +40,17 @@ export function loudness(options = {}) {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
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.";
|
|
43
|
+
export const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use half-duplex (`halfDuplex`) or push-to-talk, or pass `assumeNoEcho` if you know it does.";
|
|
44
44
|
/**
|
|
45
45
|
* Leave the microphone open on `conversation`: every utterance the
|
|
46
|
-
* detector hears becomes a turn, and talking over the agent stops it
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* detector hears becomes a turn, and talking over the agent stops it — or,
|
|
47
|
+
* with `halfDuplex`, nothing is heard while the agent speaks. Answers
|
|
48
|
+
* `ok: false` with the reason, in words an app can show, where the agent
|
|
49
|
+
* would hear itself or the microphone would not open — half-duplex and
|
|
50
|
+
* push-to-talk still work there.
|
|
50
51
|
*/
|
|
51
52
|
export async function listen(conversation, audio, options = {}) {
|
|
52
|
-
if (!options.assumeNoEcho) {
|
|
53
|
+
if (!options.assumeNoEcho && !options.halfDuplex) {
|
|
53
54
|
const echo = audio.handsFree
|
|
54
55
|
? await audio.handsFree()
|
|
55
56
|
: { ok: false, reason: NO_ECHO_ANSWER };
|
|
@@ -80,6 +81,12 @@ class HandsFree {
|
|
|
80
81
|
this.playingTurn = null;
|
|
81
82
|
/** The reply a barge-in stopped: its late windows are not the agent speaking. */
|
|
82
83
|
this.stoppedTurn = null;
|
|
84
|
+
/** Half-duplex, on that clock: the agent's voice as scheduled, in runs of frames back to back. */
|
|
85
|
+
this.voice = [];
|
|
86
|
+
/** The clock as last read, to see it go back. */
|
|
87
|
+
this.read = 0;
|
|
88
|
+
/** Half-duplex with no clock: audio time since the voice was last playing. */
|
|
89
|
+
this.sinceVoiceMs = Number.POSITIVE_INFINITY;
|
|
83
90
|
this.off = [];
|
|
84
91
|
this.conversation = conversation;
|
|
85
92
|
this.audio = audio;
|
|
@@ -89,12 +96,18 @@ class HandsFree {
|
|
|
89
96
|
this.startAfterMs = options.startAfterMs ?? 100;
|
|
90
97
|
this.wordsWithinMs = options.wordsWithinMs ?? 3000;
|
|
91
98
|
this.ringFrames = Math.ceil(((options.preRollMs ?? 300) + this.startAfterMs) / FRAME_MS);
|
|
99
|
+
this.halfDuplex = options.halfDuplex === true;
|
|
100
|
+
this.afterVoiceMs = options.afterVoiceMs ?? 300;
|
|
101
|
+
this.clock = this.halfDuplex ? clockOf(audio) : null;
|
|
92
102
|
}
|
|
93
103
|
/** The microphone, and everything watched: `null`, or why it did not open. */
|
|
94
104
|
async start() {
|
|
95
105
|
this.off.push(this.conversation.onFrame((frame) => this.frame(frame)), this.audio.onDrained(() => {
|
|
96
106
|
this.playing = false;
|
|
97
107
|
}));
|
|
108
|
+
if (this.clock) {
|
|
109
|
+
this.off.push(this.clock.onScheduled((audio) => this.scheduled(audio)), this.clock.onStopped(() => this.cut()));
|
|
110
|
+
}
|
|
98
111
|
const interrupted = this.audio.onInterruption?.((now) => this.interrupted(now));
|
|
99
112
|
if (interrupted)
|
|
100
113
|
this.off.push(interrupted);
|
|
@@ -119,7 +132,14 @@ class HandsFree {
|
|
|
119
132
|
async capture() {
|
|
120
133
|
let granted;
|
|
121
134
|
try {
|
|
122
|
-
({ rate: granted } = await this.audio.startCapture((pcm) => this.pcm(pcm), {
|
|
135
|
+
({ rate: granted } = await this.audio.startCapture((pcm) => this.pcm(pcm), {
|
|
136
|
+
rate: this.options.rate ?? 16000,
|
|
137
|
+
// On the adapter's word that it can: half-duplex and the app's
|
|
138
|
+
// own word need none, and a phone's sounds better without.
|
|
139
|
+
...(this.options.halfDuplex || this.options.assumeNoEcho
|
|
140
|
+
? {}
|
|
141
|
+
: { echoCancellation: true }),
|
|
142
|
+
}));
|
|
123
143
|
}
|
|
124
144
|
catch (failure) {
|
|
125
145
|
// swallowed: it is the answer's reason, the app's to show.
|
|
@@ -170,11 +190,16 @@ class HandsFree {
|
|
|
170
190
|
void this.capture().then((failed) => {
|
|
171
191
|
if (this.state !== "paused")
|
|
172
192
|
return;
|
|
173
|
-
|
|
174
|
-
if (failed === null)
|
|
193
|
+
if (failed === null) {
|
|
175
194
|
this.set("listening");
|
|
176
|
-
|
|
177
|
-
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Still taken, or gone: stopped, and the app's next `listen` tries
|
|
198
|
+
// again. Given back, so what the capture set up is undone.
|
|
199
|
+
this.close();
|
|
200
|
+
this.audio.stopCapture().catch(() => {
|
|
201
|
+
// swallowed: nothing was open; the adapter has let go either way.
|
|
202
|
+
});
|
|
178
203
|
});
|
|
179
204
|
}
|
|
180
205
|
pcm(buffer) {
|
|
@@ -187,14 +212,25 @@ class HandsFree {
|
|
|
187
212
|
samples.set(this.carry, 0);
|
|
188
213
|
samples.set(fresh, this.carry.length);
|
|
189
214
|
}
|
|
215
|
+
// Half-duplex: the last sample is heard now, on the player's clock.
|
|
216
|
+
const heard = this.clock ? this.heardNow() : 0;
|
|
190
217
|
let at = 0;
|
|
191
218
|
for (; at + this.frameSamples <= samples.length; at += this.frameSamples) {
|
|
192
|
-
|
|
219
|
+
const end = at + this.frameSamples;
|
|
220
|
+
this.step(samples.slice(at, end), heard - (samples.length - end) / this.rate);
|
|
193
221
|
}
|
|
194
222
|
this.carry = samples.slice(at);
|
|
195
223
|
}
|
|
196
|
-
|
|
197
|
-
|
|
224
|
+
/** One 20 ms frame; `endsAt`, half-duplex on a clock, when its last sample was heard. */
|
|
225
|
+
step(frame, endsAt) {
|
|
226
|
+
if (this.halfDuplex) {
|
|
227
|
+
const ago = this.voiceAgo(endsAt);
|
|
228
|
+
if (ago === 0 || ago < this.afterVoiceMs) {
|
|
229
|
+
this.voiceHeard(ago === 0 ? null : frame);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const voice = this.detector.push(frame, this.rate, this.playing && !this.halfDuplex);
|
|
198
234
|
const talking = voice === "talking";
|
|
199
235
|
if (talking) {
|
|
200
236
|
this.talkMs += FRAME_MS;
|
|
@@ -206,9 +242,7 @@ class HandsFree {
|
|
|
206
242
|
}
|
|
207
243
|
const open = this.open;
|
|
208
244
|
if (!open) {
|
|
209
|
-
this.
|
|
210
|
-
if (this.ring.length > this.ringFrames)
|
|
211
|
-
this.ring.shift();
|
|
245
|
+
this.keep(frame);
|
|
212
246
|
if (this.waitForQuiet && this.quietMs >= this.endAfterMs) {
|
|
213
247
|
this.waitForQuiet = false;
|
|
214
248
|
}
|
|
@@ -228,12 +262,96 @@ class HandsFree {
|
|
|
228
262
|
this.end();
|
|
229
263
|
}
|
|
230
264
|
}
|
|
265
|
+
/** A frame of the room kept for the next utterance's pre-roll. */
|
|
266
|
+
keep(frame) {
|
|
267
|
+
this.ring.push(frame);
|
|
268
|
+
if (this.ring.length > this.ringFrames)
|
|
269
|
+
this.ring.shift();
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Half-duplex: ms between the agent's voice last heard and the frame
|
|
273
|
+
* ending at `endsAt` — 0 while it is heard, infinite before it ever was.
|
|
274
|
+
*/
|
|
275
|
+
voiceAgo(endsAt) {
|
|
276
|
+
if (!this.clock) {
|
|
277
|
+
// Taken at the adapter's word: playing from `audio.start` to `onDrained`.
|
|
278
|
+
this.sinceVoiceMs = this.playing ? 0 : this.sinceVoiceMs + FRAME_MS;
|
|
279
|
+
return this.playing ? 0 : this.sinceVoiceMs;
|
|
280
|
+
}
|
|
281
|
+
const startsAt = endsAt - FRAME_MS / 1000;
|
|
282
|
+
let ago = Number.POSITIVE_INFINITY;
|
|
283
|
+
for (const run of this.voice) {
|
|
284
|
+
if (run.from < endsAt && run.until > startsAt)
|
|
285
|
+
return 0;
|
|
286
|
+
if (run.until <= startsAt)
|
|
287
|
+
ago = (startsAt - run.until) * 1000;
|
|
288
|
+
}
|
|
289
|
+
return ago;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Half-duplex: the agent's voice is heard, or its tail (`tail`, the
|
|
293
|
+
* frame). The voice has the floor — an utterance open ends as a turn
|
|
294
|
+
* before any of it is sent — and the detector rests; nothing of the
|
|
295
|
+
* voice is pre-roll, its tail is.
|
|
296
|
+
*/
|
|
297
|
+
voiceHeard(tail) {
|
|
298
|
+
if (this.open)
|
|
299
|
+
this.end();
|
|
300
|
+
this.talkMs = 0;
|
|
301
|
+
this.quietMs = 0;
|
|
302
|
+
if (tail)
|
|
303
|
+
this.keep(tail);
|
|
304
|
+
else
|
|
305
|
+
this.ring = [];
|
|
306
|
+
}
|
|
307
|
+
/** The clock now; one that went back — a new context, the phone's for a reply at another rate — leaves the runs kept on the old one. */
|
|
308
|
+
now() {
|
|
309
|
+
const now = this.clock.now();
|
|
310
|
+
// The stop that came with it was told; the new run starts at once.
|
|
311
|
+
if (now < this.read)
|
|
312
|
+
this.voice = [];
|
|
313
|
+
this.read = now;
|
|
314
|
+
return now;
|
|
315
|
+
}
|
|
316
|
+
/** What is heard now on the clock, the runs long past forgotten. */
|
|
317
|
+
heardNow() {
|
|
318
|
+
this.now();
|
|
319
|
+
const heard = this.clock.heardTime();
|
|
320
|
+
const past = heard - (this.afterVoiceMs + 1000) / 1000;
|
|
321
|
+
while (this.voice.length > 1 && (this.voice[0]?.until ?? 0) < past) {
|
|
322
|
+
this.voice.shift();
|
|
323
|
+
}
|
|
324
|
+
return heard;
|
|
325
|
+
}
|
|
326
|
+
/** A frame of the reply, queued to start at `startsAt`: its run of the voice, or a new one after a gap. */
|
|
327
|
+
scheduled(audio) {
|
|
328
|
+
this.now();
|
|
329
|
+
const until = audio.startsAt + audio.duration;
|
|
330
|
+
const last = this.voice[this.voice.length - 1];
|
|
331
|
+
// Back to back with the one before, to a millisecond's rounding.
|
|
332
|
+
if (last &&
|
|
333
|
+
audio.startsAt >= last.from &&
|
|
334
|
+
audio.startsAt <= last.until + 0.001) {
|
|
335
|
+
last.until = Math.max(last.until, until);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
this.voice.push({ from: audio.startsAt, until });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** The player dropped what it had queued: the voice ends where the clock is. */
|
|
342
|
+
cut() {
|
|
343
|
+
const now = this.now();
|
|
344
|
+
this.voice = this.voice.filter((run) => run.from < now);
|
|
345
|
+
for (const run of this.voice)
|
|
346
|
+
run.until = Math.min(run.until, now);
|
|
347
|
+
}
|
|
231
348
|
begin() {
|
|
232
|
-
|
|
349
|
+
// Half-duplex never opens over the voice: no barge-in to decide.
|
|
350
|
+
const barging = this.playing && !this.halfDuplex;
|
|
233
351
|
const options = this.options;
|
|
234
352
|
const id = this.conversation.startAudio({
|
|
235
353
|
rate: this.rate,
|
|
236
|
-
bargeIn:
|
|
354
|
+
bargeIn: !this.halfDuplex,
|
|
237
355
|
...(options.language ? { language: options.language } : {}),
|
|
238
356
|
...(options.model ? { model: options.model } : {}),
|
|
239
357
|
...(options.speech === undefined ? {} : { speech: options.speech }),
|
|
@@ -273,6 +391,16 @@ class HandsFree {
|
|
|
273
391
|
this.options.onState?.(state);
|
|
274
392
|
}
|
|
275
393
|
}
|
|
394
|
+
/** The adapter's `PlaybackClock`, when it has one: both of this SDK's do; one of an app's own need not. */
|
|
395
|
+
function clockOf(audio) {
|
|
396
|
+
const clock = audio;
|
|
397
|
+
return typeof clock.now === "function" &&
|
|
398
|
+
typeof clock.heardTime === "function" &&
|
|
399
|
+
typeof clock.onScheduled === "function" &&
|
|
400
|
+
typeof clock.onStopped === "function"
|
|
401
|
+
? audio
|
|
402
|
+
: null;
|
|
403
|
+
}
|
|
276
404
|
/** 16-bit little-endian PCM as samples, on a big-endian device too. */
|
|
277
405
|
function samplesOf(buffer) {
|
|
278
406
|
const count = buffer.byteLength >> 1;
|
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";
|
|
@@ -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;
|
package/dist/web-audio/index.js
CHANGED
|
@@ -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):
|
|
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():
|
|
136
|
+
stop(): boolean;
|
|
98
137
|
/** Release the audio device. Safe to call more than once. */
|
|
99
138
|
close(): Promise<void>;
|
|
100
139
|
}
|
package/dist/web-audio/player.js
CHANGED
|
@@ -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 +
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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,70 @@
|
|
|
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. Half-duplex runs
|
|
23
|
+
* anywhere (`listen`'s `halfDuplex`): it never asks.
|
|
24
|
+
* - **The clock and the frames are the app's** (`PlaybackClock`): each
|
|
25
|
+
* frame told as it is queued, with when it starts; the stop; what the
|
|
26
|
+
* microphone captures. A handler that throws is rethrown on its own turn,
|
|
27
|
+
* never inside `play`, `stop` or a capture — a lipsync bug must not cut
|
|
28
|
+
* the voice.
|
|
29
|
+
* - **The microphone, its processing and the speaker are the app's to
|
|
30
|
+
* choose** (`DeviceChoices`): the speaker through
|
|
31
|
+
* `AudioContext.setSinkId`, Chrome's and Edge's alone. Whether echo is
|
|
32
|
+
* still cancelled with a speaker that is not the default is Chrome's,
|
|
33
|
+
* and unchecked (left-overs #222).
|
|
22
34
|
*/
|
|
23
|
-
import type { AgentAudio } from "../audio.js";
|
|
35
|
+
import type { AgentAudio, AudioDevice, DeviceChoices, PlaybackClock, Processing, ScheduledAudio } from "../audio.js";
|
|
36
|
+
export interface WebAudioOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Milliseconds a reply's first frame — or one after a gap — waits before
|
|
39
|
+
* it plays: 60 unless set. Longer gives an app time to read each frame
|
|
40
|
+
* before it is heard (`onScheduled`), and adds as much to every first word.
|
|
41
|
+
*/
|
|
42
|
+
leadInMs?: number;
|
|
43
|
+
/** The microphone, by its `inputs()` id: the default unless set. */
|
|
44
|
+
input?: string;
|
|
45
|
+
/** The browser's cleanup of the microphone: each on unless `false`. */
|
|
46
|
+
processing?: Processing;
|
|
47
|
+
}
|
|
48
|
+
/** What `play` is given: the frame's rate, and what `onScheduled` passes on when it has it. */
|
|
49
|
+
type Frame = Parameters<AgentAudio["play"]>[1];
|
|
24
50
|
/** Why hands-free is refused on an iPhone or iPad. */
|
|
25
|
-
export declare const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use push-to-talk.";
|
|
51
|
+
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 half-duplex or push-to-talk.";
|
|
26
52
|
/** Why it is refused in a browser that cannot cancel echo at all. */
|
|
27
|
-
export declare const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
|
|
28
|
-
|
|
53
|
+
export declare const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use half-duplex or push-to-talk.";
|
|
54
|
+
/** Why it is refused with echo cancellation turned off (`setProcessing`). */
|
|
55
|
+
export declare const ECHO_OFF = "Echo cancellation is turned off, so the agent would hear itself. Turn it on, or use half-duplex or push-to-talk.";
|
|
56
|
+
export declare class WebAudio implements AgentAudio, PlaybackClock, DeviceChoices {
|
|
29
57
|
private player;
|
|
30
58
|
private recorder;
|
|
31
59
|
private onPcm;
|
|
32
60
|
private readonly drained;
|
|
61
|
+
private readonly scheduled;
|
|
62
|
+
private readonly stopped;
|
|
63
|
+
private readonly captured;
|
|
64
|
+
private readonly leadIn?;
|
|
65
|
+
private input?;
|
|
66
|
+
private processing;
|
|
67
|
+
private sink?;
|
|
68
|
+
constructor(options?: WebAudioOptions);
|
|
33
69
|
/** Make the player and unlock it. **Call it from the click**, before any `await`. */
|
|
34
70
|
prime(): Promise<void>;
|
|
35
|
-
play(pcm: ArrayBuffer, frame:
|
|
36
|
-
sample_rate: number;
|
|
37
|
-
}): void;
|
|
71
|
+
play(pcm: ArrayBuffer, frame: Pick<Frame, "sample_rate"> & Partial<Frame>): void;
|
|
38
72
|
stop(): void;
|
|
39
73
|
onDrained(fn: () => void): () => void;
|
|
74
|
+
now(): number;
|
|
75
|
+
heardTime(): number;
|
|
76
|
+
onScheduled(fn: (audio: ScheduledAudio) => void): () => void;
|
|
77
|
+
onStopped(fn: () => void): () => void;
|
|
78
|
+
onCapture(fn: (pcm: ArrayBuffer, rate: number) => void): () => void;
|
|
79
|
+
inputs(): Promise<AudioDevice[]>;
|
|
80
|
+
setInput(id?: string): void;
|
|
81
|
+
setProcessing(processing: Processing): void;
|
|
82
|
+
outputs(): Promise<AudioDevice[]>;
|
|
83
|
+
setOutputDevice(id: string): Promise<boolean>;
|
|
84
|
+
get canChooseOutput(): boolean;
|
|
40
85
|
/** At the recorder's own rate, 24 kHz, whatever is asked. */
|
|
41
86
|
startCapture(onPcm: (pcm: ArrayBuffer) => void, _options?: {
|
|
42
87
|
rate?: number;
|
|
@@ -54,3 +99,4 @@ export declare class WebAudio implements AgentAudio {
|
|
|
54
99
|
/** The conversation is over: the player's context goes too. */
|
|
55
100
|
dispose(): void;
|
|
56
101
|
}
|
|
102
|
+
export {};
|
|
@@ -2,23 +2,31 @@ import { PcmPlayer } from "./player.js";
|
|
|
2
2
|
/** The reply's loudness while the person may be talking over it: about -10 dB. */
|
|
3
3
|
const DUCKED = 0.3;
|
|
4
4
|
/** Why hands-free is refused on an iPhone or iPad. */
|
|
5
|
-
export const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use push-to-talk.";
|
|
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 half-duplex or push-to-talk.";
|
|
6
6
|
/** Why it is refused in a browser that cannot cancel echo at all. */
|
|
7
|
-
export const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
|
|
7
|
+
export const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use half-duplex or 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 half-duplex or 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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({
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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.
|
|
3
|
+
"version": "0.17.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",
|