@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 +39 -4
- package/dist/audio.d.ts +33 -7
- package/dist/conversation.d.ts +22 -0
- package/dist/conversation.js +35 -0
- package/dist/events.d.ts +13 -0
- package/dist/hands-free.d.ts +112 -0
- package/dist/hands-free.js +285 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol.gen.d.ts +1 -1
- package/dist/protocol.gen.js +1 -1
- package/dist/web-audio/index.d.ts +15 -0
- package/dist/web-audio/index.js +16 -0
- package/dist/web-audio/player.d.ts +100 -0
- package/dist/web-audio/player.js +201 -0
- package/dist/web-audio/recorder.d.ts +114 -0
- package/dist/web-audio/recorder.js +255 -0
- package/dist/web-audio/web-audio.d.ts +56 -0
- package/dist/web-audio/web-audio.js +85 -0
- package/package.json +11 -2
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The microphone, as the same raw PCM this socket already plays.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `player.ts`: 16-bit signed little-endian mono, at 24 kHz —
|
|
5
|
+
* the rate the socket's audio defaults to (`DEFAULT_SAMPLE_RATE`), and one
|
|
6
|
+
* of `RECORDING_RATES`.
|
|
7
|
+
*
|
|
8
|
+
* **No `MediaRecorder`, and that is the whole design.** It produces a
|
|
9
|
+
* *container* — webm/opus, whatever the browser feels like — which the socket
|
|
10
|
+
* cannot take: binary frames there are bare samples with no header, and the
|
|
11
|
+
* server would have to decode a container to find out what it was given. Web
|
|
12
|
+
* Audio hands over float samples instead, and converting those to 16-bit
|
|
13
|
+
* integers is four lines.
|
|
14
|
+
*
|
|
15
|
+
* Three things make it work in practice:
|
|
16
|
+
*
|
|
17
|
+
* - **This context is opened at 24 kHz** rather than resampled after the
|
|
18
|
+
* fact. Browsers accept a `sampleRate` on the constructor and do the
|
|
19
|
+
* conversion in the audio thread, which is both better and free. (The
|
|
20
|
+
* player's context is the device's own: `player.ts` says why.)
|
|
21
|
+
* - **`AudioWorklet` with a `ScriptProcessor` fallback.** The worklet runs off
|
|
22
|
+
* the main thread, so a busy React render cannot drop samples — which is
|
|
23
|
+
* heard as a clipped word rather than as a stutter, and is exactly the kind
|
|
24
|
+
* of fault that would be blamed on the recogniser.
|
|
25
|
+
* - **The track is stopped on `stop()`**, not merely disconnected. A live
|
|
26
|
+
* `MediaStreamTrack` leaves the browser's recording indicator on, and a
|
|
27
|
+
* microphone that looks like it is still listening is a trust problem
|
|
28
|
+
* whatever the code is doing.
|
|
29
|
+
*
|
|
30
|
+
* Loaded on the first press (`web-audio.ts`), so a page that never records
|
|
31
|
+
* never parses it.
|
|
32
|
+
*/
|
|
33
|
+
/** The rate this records at, whatever is asked: the socket's default. */
|
|
34
|
+
export const RECORD_SAMPLE_RATE = 24000;
|
|
35
|
+
/** Multiplier from the -1..1 floats Web Audio gives to 16-bit signed. 32767,
|
|
36
|
+
* not 32768, so a full-scale sample cannot wrap to the opposite sign. */
|
|
37
|
+
const FULL_SCALE = 32767;
|
|
38
|
+
/** Samples per delivered chunk — about 85 ms at 24 kHz. Small enough that the
|
|
39
|
+
* upload overlaps the talking, large enough not to be one WebSocket frame per
|
|
40
|
+
* animation tick.
|
|
41
|
+
*
|
|
42
|
+
* **Applied to the worklet path too.** The worklet posts every 128-sample
|
|
43
|
+
* render quantum; one WebSocket frame a post is about 188 frames a second of
|
|
44
|
+
* 256 bytes, each a TLS record and a message the end user DO wakes for —
|
|
45
|
+
* sixteen times as many as at this size. Batched here, on the main thread,
|
|
46
|
+
* where the tail can be flushed synchronously when the button is released. */
|
|
47
|
+
export const FRAME_SAMPLES = 2048;
|
|
48
|
+
const WORKLET = `
|
|
49
|
+
class Tap extends AudioWorkletProcessor {
|
|
50
|
+
process(inputs) {
|
|
51
|
+
const channel = inputs[0]?.[0];
|
|
52
|
+
// A disconnected or silent-by-design input yields an empty array rather
|
|
53
|
+
// than zeroes; forwarding that would post empty frames forever.
|
|
54
|
+
if (channel && channel.length) this.port.postMessage(channel.slice());
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
registerProcessor("pcm-tap", Tap);
|
|
59
|
+
`;
|
|
60
|
+
/** Float samples to 16-bit signed little-endian, which is what the wire wants. */
|
|
61
|
+
export function toPcm16(samples) {
|
|
62
|
+
const out = new DataView(new ArrayBuffer(samples.length * 2));
|
|
63
|
+
for (let i = 0; i < samples.length; i++) {
|
|
64
|
+
// Clamped: a sample outside -1..1 is legal in Web Audio and wraps rather
|
|
65
|
+
// than clips once it is an integer, which is heard as a loud crack.
|
|
66
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
67
|
+
out.setInt16(i * 2, Math.round(clamped * FULL_SCALE), true);
|
|
68
|
+
}
|
|
69
|
+
return out.buffer;
|
|
70
|
+
}
|
|
71
|
+
export class PcmRecorder {
|
|
72
|
+
constructor(options) {
|
|
73
|
+
/** Whether chunks may still be delivered.
|
|
74
|
+
*
|
|
75
|
+
* **Cleared synchronously by `stop()`, and checked before every delivery.**
|
|
76
|
+
* Disconnecting a node does not un-post the messages it has already sent:
|
|
77
|
+
* the worklet runs on the audio thread and its `port.postMessage` calls
|
|
78
|
+
* arrive on the main thread as ordinary tasks, so a handful land *after*
|
|
79
|
+
* `stop()` has returned.
|
|
80
|
+
*
|
|
81
|
+
* That is not a tidiness problem. The caller sends each chunk into an open
|
|
82
|
+
* `user_audio` window and closes that window immediately after stopping —
|
|
83
|
+
* so a late chunk is binary audio arriving with no window open, which the
|
|
84
|
+
* edge answers by closing the connection. That is how it shows: hold,
|
|
85
|
+
* release, hold, release, and the socket dies with
|
|
86
|
+
* "Binary audio arrived with no user_audio.start before it."
|
|
87
|
+
*/
|
|
88
|
+
this.live = false;
|
|
89
|
+
this.context = null;
|
|
90
|
+
this.stream = null;
|
|
91
|
+
this.node = null;
|
|
92
|
+
this.source = null;
|
|
93
|
+
/** Samples not yet delivered — see `FRAME_SAMPLES`. */
|
|
94
|
+
this.pending = [];
|
|
95
|
+
this.pendingSamples = 0;
|
|
96
|
+
this.onChunk = options.onChunk;
|
|
97
|
+
this.sampleRate = options.sampleRate ?? RECORD_SAMPLE_RATE;
|
|
98
|
+
}
|
|
99
|
+
/** True once `start()` has a live microphone and has not been stopped. */
|
|
100
|
+
get recording() {
|
|
101
|
+
return this.live;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Hz the delivered samples actually are.
|
|
105
|
+
*
|
|
106
|
+
* The context is *asked* for `sampleRate`; browsers honour it or throw, and
|
|
107
|
+
* this reads back what was granted rather than repeating the request. It is
|
|
108
|
+
* what a caller must declare on `user_audio.start` — a declared rate the
|
|
109
|
+
* samples are not is the wrong pitch and a wrong bill, behind a 200.
|
|
110
|
+
*/
|
|
111
|
+
get rate() {
|
|
112
|
+
return this.context?.sampleRate ?? this.sampleRate;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Ask for the microphone and begin delivering samples.
|
|
116
|
+
*
|
|
117
|
+
* Rejects if permission is refused, which is the caller's to report — a mic
|
|
118
|
+
* button that silently does nothing after a denied prompt is the worst of the
|
|
119
|
+
* available outcomes.
|
|
120
|
+
*/
|
|
121
|
+
async start() {
|
|
122
|
+
if (this.stream)
|
|
123
|
+
return;
|
|
124
|
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
125
|
+
audio: {
|
|
126
|
+
// The browser's own cleanup, which is better than anything worth doing
|
|
127
|
+
// here and costs nothing: a recogniser fed room echo transcribes it.
|
|
128
|
+
echoCancellation: true,
|
|
129
|
+
noiseSuppression: true,
|
|
130
|
+
autoGainControl: true,
|
|
131
|
+
channelCount: 1,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
this.context = new AudioContext({ sampleRate: this.sampleRate });
|
|
136
|
+
// Autoplay policy suspends a context created outside a gesture. Pressing
|
|
137
|
+
// a microphone button *is* one, so this resolves immediately — it is here
|
|
138
|
+
// for the case where the button was held from a restored tab.
|
|
139
|
+
if (this.context.state === "suspended")
|
|
140
|
+
await this.context.resume();
|
|
141
|
+
this.source = this.context.createMediaStreamSource(this.stream);
|
|
142
|
+
this.node = await this.tap(this.context);
|
|
143
|
+
}
|
|
144
|
+
catch (failure) {
|
|
145
|
+
// **The track is released on the way out** (console 0.197.0).
|
|
146
|
+
// `getUserMedia` had succeeded, so a context that refused the rate or a
|
|
147
|
+
// worklet that failed to load used to leave a live microphone —
|
|
148
|
+
// indicator on — behind an error the caller reported as "could not
|
|
149
|
+
// open the microphone".
|
|
150
|
+
this.stop();
|
|
151
|
+
throw failure;
|
|
152
|
+
}
|
|
153
|
+
// **Last, after everything that can fail.** Connecting is what starts the
|
|
154
|
+
// samples flowing, so anything between it and here would be a window in
|
|
155
|
+
// which chunks arrive before the caller has been told recording began.
|
|
156
|
+
this.live = true;
|
|
157
|
+
this.source.connect(this.node);
|
|
158
|
+
// **Not connected to the destination.** Routing the microphone to the
|
|
159
|
+
// speakers is feedback, and a `ScriptProcessorNode` needs a downstream to
|
|
160
|
+
// be pulled at all — so that fallback connects to a muted gain instead.
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Stop, release the microphone, and turn the browser's indicator off.
|
|
164
|
+
*
|
|
165
|
+
* Returns the **tail**: samples batched and not yet delivered when the button
|
|
166
|
+
* was released — up to one frame, ~85 ms, the end of the last word. Returned
|
|
167
|
+
* rather than delivered through `onChunk`, because the caller has by now
|
|
168
|
+
* stopped accepting chunks (`stop()` is what it calls to stop them) and
|
|
169
|
+
* decides for itself whether the tail goes on the wire before `user_audio.end`.
|
|
170
|
+
* `null` when there is nothing held.
|
|
171
|
+
*/
|
|
172
|
+
stop() {
|
|
173
|
+
// **First, before anything else.** Everything below is asynchronous in
|
|
174
|
+
// effect — a disconnected node's already-posted messages still arrive — and
|
|
175
|
+
// this is the only part that takes effect immediately.
|
|
176
|
+
this.live = false;
|
|
177
|
+
const tail = this.pendingSamples ? toPcm16(this.takePending()) : null;
|
|
178
|
+
this.source?.disconnect();
|
|
179
|
+
this.node?.disconnect();
|
|
180
|
+
for (const track of this.stream?.getTracks() ?? [])
|
|
181
|
+
track.stop();
|
|
182
|
+
void this.context?.close();
|
|
183
|
+
this.source = null;
|
|
184
|
+
this.node = null;
|
|
185
|
+
this.stream = null;
|
|
186
|
+
this.context = null;
|
|
187
|
+
return tail;
|
|
188
|
+
}
|
|
189
|
+
/** Batch a block of samples, delivering whole frames as they fill. */
|
|
190
|
+
deliver(samples) {
|
|
191
|
+
if (!this.live)
|
|
192
|
+
return;
|
|
193
|
+
this.pending.push(samples);
|
|
194
|
+
this.pendingSamples += samples.length;
|
|
195
|
+
while (this.pendingSamples >= FRAME_SAMPLES) {
|
|
196
|
+
this.onChunk(toPcm16(this.takePending(FRAME_SAMPLES)));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Up to `count` pending samples (all of them by default), as one block. */
|
|
200
|
+
takePending(count = this.pendingSamples) {
|
|
201
|
+
const out = new Float32Array(Math.min(count, this.pendingSamples));
|
|
202
|
+
let filled = 0;
|
|
203
|
+
while (filled < out.length) {
|
|
204
|
+
const head = this.pending[0];
|
|
205
|
+
const take = Math.min(head.length, out.length - filled);
|
|
206
|
+
out.set(head.subarray(0, take), filled);
|
|
207
|
+
filled += take;
|
|
208
|
+
if (take === head.length)
|
|
209
|
+
this.pending.shift();
|
|
210
|
+
else
|
|
211
|
+
this.pending[0] = head.subarray(take);
|
|
212
|
+
}
|
|
213
|
+
this.pendingSamples -= out.length;
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
async tap(context) {
|
|
217
|
+
if (context.audioWorklet) {
|
|
218
|
+
try {
|
|
219
|
+
const url = URL.createObjectURL(new Blob([WORKLET], { type: "application/javascript" }));
|
|
220
|
+
try {
|
|
221
|
+
await context.audioWorklet.addModule(url);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
// Revoked as soon as it is loaded: the module is compiled by then,
|
|
225
|
+
// and a blob URL left behind is a leak for the tab's lifetime.
|
|
226
|
+
URL.revokeObjectURL(url);
|
|
227
|
+
}
|
|
228
|
+
const node = new AudioWorkletNode(context, "pcm-tap");
|
|
229
|
+
node.port.onmessage = (event) => {
|
|
230
|
+
this.deliver(event.data);
|
|
231
|
+
};
|
|
232
|
+
return node;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// swallowed: fall through. Worklets are unavailable over plain HTTP on
|
|
236
|
+
// some browsers and the fallback is correct, just less pleasant.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// `ScriptProcessorNode` is deprecated and still universally supported. It
|
|
240
|
+
// runs on the main thread, so a heavy render can drop a block — acceptable
|
|
241
|
+
// as a fallback, not as the default.
|
|
242
|
+
const node = context.createScriptProcessor(FRAME_SAMPLES, 1, 0);
|
|
243
|
+
node.onaudioprocess = (event) => {
|
|
244
|
+
// Copied: the buffer is the node's and is reused for the next block.
|
|
245
|
+
this.deliver(event.inputBuffer.getChannelData(0).slice());
|
|
246
|
+
};
|
|
247
|
+
// Silent sink: without a downstream connection the node is never pulled and
|
|
248
|
+
// `onaudioprocess` never fires, which reads as a dead microphone.
|
|
249
|
+
const sink = context.createGain();
|
|
250
|
+
sink.gain.value = 0;
|
|
251
|
+
node.connect(sink);
|
|
252
|
+
sink.connect(context.destination);
|
|
253
|
+
return node;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser's player and recorder as an `AgentAudio`: what
|
|
3
|
+
* `@lumavox/react`'s `useAgent` plays every reply through and records with
|
|
4
|
+
* — the console's since client 0.8.0, which it was written for.
|
|
5
|
+
*
|
|
6
|
+
* - **The player is made on the first click** (`prime`): every browser
|
|
7
|
+
* suspends an `AudioContext` created outside a user gesture, and a
|
|
8
|
+
* suspended one accepts every scheduling call silently.
|
|
9
|
+
* - **Each frame says its rate**: `audio.start` carries the window's own
|
|
10
|
+
* (the agent's voice is re-read every turn), and the player makes each
|
|
11
|
+
* buffer at it.
|
|
12
|
+
* - **The recorder is loaded on the first press**: `recorder.ts` holds the
|
|
13
|
+
* AudioWorklet setup, kept out of a page that never records. It records
|
|
14
|
+
* at 24 kHz whatever is asked — a rate the socket takes. Its tail — the
|
|
15
|
+
* last partial frame, the end of the last word — is delivered before
|
|
16
|
+
* `stopCapture` settles, or the transcript never gets it.
|
|
17
|
+
* - **Hands-free where the browser cancels echo** (`handsFree`): Chrome,
|
|
18
|
+
* Edge and Firefox remove a tab's own sound from its microphone — Firefox
|
|
19
|
+
* only with the context at the device's rate, which the player keeps.
|
|
20
|
+
* Every browser on an iPhone or iPad is WebKit, whose canceller fails
|
|
21
|
+
* three ways (WebKit bugs 218012, 311451, 326286): refused there.
|
|
22
|
+
*/
|
|
23
|
+
import type { AgentAudio } from "../audio.js";
|
|
24
|
+
/** 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.";
|
|
26
|
+
/** 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
|
+
export declare class WebAudio implements AgentAudio {
|
|
29
|
+
private player;
|
|
30
|
+
private recorder;
|
|
31
|
+
private onPcm;
|
|
32
|
+
private readonly drained;
|
|
33
|
+
/** Make the player and unlock it. **Call it from the click**, before any `await`. */
|
|
34
|
+
prime(): Promise<void>;
|
|
35
|
+
play(pcm: ArrayBuffer, frame: {
|
|
36
|
+
sample_rate: number;
|
|
37
|
+
}): void;
|
|
38
|
+
stop(): void;
|
|
39
|
+
onDrained(fn: () => void): () => void;
|
|
40
|
+
/** At the recorder's own rate, 24 kHz, whatever is asked. */
|
|
41
|
+
startCapture(onPcm: (pcm: ArrayBuffer) => void, _options?: {
|
|
42
|
+
rate?: number;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
rate: number;
|
|
45
|
+
}>;
|
|
46
|
+
stopCapture(): Promise<void>;
|
|
47
|
+
duck(on: boolean): void;
|
|
48
|
+
handsFree(): Promise<{
|
|
49
|
+
ok: true;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
reason: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** The conversation is over: the player's context goes too. */
|
|
55
|
+
dispose(): void;
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { PcmPlayer } from "./player.js";
|
|
2
|
+
/** The reply's loudness while the person may be talking over it: about -10 dB. */
|
|
3
|
+
const DUCKED = 0.3;
|
|
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.";
|
|
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.";
|
|
8
|
+
export class WebAudio {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.player = null;
|
|
11
|
+
this.recorder = null;
|
|
12
|
+
this.onPcm = null;
|
|
13
|
+
this.drained = new Set();
|
|
14
|
+
}
|
|
15
|
+
/** Make the player and unlock it. **Call it from the click**, before any `await`. */
|
|
16
|
+
prime() {
|
|
17
|
+
this.player ?? (this.player = new PcmPlayer({
|
|
18
|
+
onIdle: () => {
|
|
19
|
+
for (const fn of this.drained)
|
|
20
|
+
fn();
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
return this.player.resume();
|
|
24
|
+
}
|
|
25
|
+
play(pcm, frame) {
|
|
26
|
+
if (!this.player)
|
|
27
|
+
void this.prime();
|
|
28
|
+
const player = this.player;
|
|
29
|
+
player.setSampleRate(frame.sample_rate);
|
|
30
|
+
player.push(pcm);
|
|
31
|
+
}
|
|
32
|
+
stop() {
|
|
33
|
+
this.player?.stop();
|
|
34
|
+
}
|
|
35
|
+
onDrained(fn) {
|
|
36
|
+
this.drained.add(fn);
|
|
37
|
+
return () => {
|
|
38
|
+
this.drained.delete(fn);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** At the recorder's own rate, 24 kHz, whatever is asked. */
|
|
42
|
+
async startCapture(onPcm, _options) {
|
|
43
|
+
const { PcmRecorder } = await import("./recorder.js");
|
|
44
|
+
const mic = new PcmRecorder({ onChunk: (pcm) => this.onPcm?.(pcm) });
|
|
45
|
+
this.onPcm = onPcm;
|
|
46
|
+
this.recorder = mic;
|
|
47
|
+
try {
|
|
48
|
+
await mic.start();
|
|
49
|
+
}
|
|
50
|
+
catch (failure) {
|
|
51
|
+
if (this.recorder === mic)
|
|
52
|
+
this.recorder = null;
|
|
53
|
+
throw failure;
|
|
54
|
+
}
|
|
55
|
+
return { rate: mic.rate };
|
|
56
|
+
}
|
|
57
|
+
async stopCapture() {
|
|
58
|
+
const mic = this.recorder;
|
|
59
|
+
const deliver = this.onPcm;
|
|
60
|
+
this.recorder = null;
|
|
61
|
+
this.onPcm = null;
|
|
62
|
+
const tail = mic?.stop();
|
|
63
|
+
if (tail)
|
|
64
|
+
deliver?.(tail);
|
|
65
|
+
}
|
|
66
|
+
duck(on) {
|
|
67
|
+
this.player?.setVolume(on ? DUCKED : 1);
|
|
68
|
+
}
|
|
69
|
+
async handsFree() {
|
|
70
|
+
const devices = typeof navigator === "undefined" ? undefined : navigator.mediaDevices;
|
|
71
|
+
if (!devices?.getSupportedConstraints?.().echoCancellation) {
|
|
72
|
+
return { ok: false, reason: NO_ECHO };
|
|
73
|
+
}
|
|
74
|
+
// iPadOS says it is a Mac: a Mac with a touch screen is an iPad.
|
|
75
|
+
const ios = /iP(hone|ad|od)/.test(navigator.userAgent) ||
|
|
76
|
+
(/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
|
|
77
|
+
return ios ? { ok: false, reason: IOS_ECHO } : { ok: true };
|
|
78
|
+
}
|
|
79
|
+
/** The conversation is over: the player's context goes too. */
|
|
80
|
+
dispose() {
|
|
81
|
+
void this.stopCapture();
|
|
82
|
+
void this.player?.close();
|
|
83
|
+
this.player = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumavox/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Talk to Lumavox agents from a browser, a React Native app or Node, with an end user's ephemeral key: conversations on the agent's socket, sessions, memory and relations. Zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
"types": "./dist/index.d.ts",
|
|
17
17
|
"default": "./dist/index.js"
|
|
18
18
|
},
|
|
19
|
+
"./web-audio": {
|
|
20
|
+
"@ino/source": "./src/web-audio/index.ts",
|
|
21
|
+
"types": "./dist/web-audio/index.d.ts",
|
|
22
|
+
"default": "./dist/web-audio/index.js"
|
|
23
|
+
},
|
|
19
24
|
"./package.json": "./package.json"
|
|
20
25
|
},
|
|
21
26
|
"scripts": {
|
|
@@ -27,14 +32,18 @@
|
|
|
27
32
|
"format": "biome format --write",
|
|
28
33
|
"lint": "biome lint",
|
|
29
34
|
"test": "vitest run",
|
|
30
|
-
"test:unit": "vitest run",
|
|
35
|
+
"test:unit": "vitest run --project unit",
|
|
36
|
+
"test:browser": "vitest run --project browser",
|
|
31
37
|
"typecheck": "tsc --noEmit && tsc --noEmit -p test"
|
|
32
38
|
},
|
|
33
39
|
"devDependencies": {
|
|
34
40
|
"@biomejs/biome": "2.6.2",
|
|
35
41
|
"@hey-api/openapi-ts": "^0.99.0",
|
|
36
42
|
"@types/node": "^24",
|
|
43
|
+
"@vitest/browser": "^4.1.12",
|
|
44
|
+
"@vitest/browser-playwright": "^4.1.12",
|
|
37
45
|
"esbuild": "^0.28.1",
|
|
46
|
+
"playwright": "^1.64.1",
|
|
38
47
|
"typescript": "^6",
|
|
39
48
|
"vitest": "^4.1.12"
|
|
40
49
|
}
|