@lumavox/client 0.6.0 → 0.9.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 +22 -4
- package/dist/audio.d.ts +17 -7
- package/dist/events.d.ts +3 -1
- package/dist/gen/index.d.ts +1 -1
- package/dist/gen/types.gen.d.ts +14 -0
- package/dist/web-audio/index.d.ts +15 -0
- package/dist/web-audio/index.js +16 -0
- package/dist/web-audio/player.d.ts +93 -0
- package/dist/web-audio/player.js +187 -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 +40 -0
- package/dist/web-audio/web-audio.js +66 -0
- package/package.json +11 -2
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
|
|
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,27 @@ const turn = await conversation.endAudio(); // a spoken turn
|
|
|
126
128
|
const words = await conversation.endAudio({ transcribeOnly: true });
|
|
127
129
|
```
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
player takes `onAudio`'s. `floatToPcm16`, `pcm16ToFloat`,
|
|
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
|
+
|
|
134
152
|
## Runtimes
|
|
135
153
|
|
|
136
154
|
Browsers, React Native (Hermes, ES2020 — and each binary frame reaches JS
|
package/dist/audio.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
10
|
+
* Voice activity detection stays the app's.
|
|
10
11
|
*/
|
|
11
12
|
export interface AgentAudio {
|
|
12
13
|
/** Queue one frame of a reply: 16-bit little-endian PCM, in wire order. */
|
|
@@ -35,4 +36,13 @@ export interface AgentAudio {
|
|
|
35
36
|
stopCapture(): Promise<void>;
|
|
36
37
|
/** Free everything: the conversation is over. */
|
|
37
38
|
dispose(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Optional: `fn(true)` when the device takes the audio away — a phone
|
|
41
|
+
* call, Siri, another app, the app leaving the screen — and `fn(false)`
|
|
42
|
+
* when it gives it back. The adapter suspends playback (a reply's frames
|
|
43
|
+
* keep queueing) and closes its recorder itself; whoever drives it throws
|
|
44
|
+
* away the recording that was open, its speaker cut off mid-sentence.
|
|
45
|
+
* Answers the unsubscribe.
|
|
46
|
+
*/
|
|
47
|
+
onInterruption?(fn: (interrupted: boolean) => void): () => void;
|
|
38
48
|
}
|
package/dist/events.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* test (`apps/edge/test/sdk.test.ts`) holds every field of every frame the
|
|
10
10
|
* real conversation object sends to these, both ways.
|
|
11
11
|
*/
|
|
12
|
-
import type { ErrorCode, RelationInfo } from "./gen/index.js";
|
|
12
|
+
import type { ErrorCode, MessageSource, RelationInfo } from "./gen/index.js";
|
|
13
13
|
import type { ServerEventName } from "./protocol.gen.js";
|
|
14
14
|
interface Frame<T extends ServerEventName> {
|
|
15
15
|
type: T;
|
|
@@ -207,6 +207,8 @@ export interface TurnDoneFrame extends Frame<"turn.done">, InTurn {
|
|
|
207
207
|
heard_seconds: number | null;
|
|
208
208
|
/** Your tools it called, in order. */
|
|
209
209
|
tool_calls: ToolCall[];
|
|
210
|
+
/** The web pages it read, with the agent's `url_context` on; `[]` for none. */
|
|
211
|
+
sources: MessageSource[];
|
|
210
212
|
timings: TurnTimings;
|
|
211
213
|
}
|
|
212
214
|
/** The turn ended early; what was written before it stopped still stands. */
|
package/dist/gen/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export type { ClientOptions, CompactionInfo, CompactRequest, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteAllFactsData, DeleteAllFactsError, DeleteAllFactsErrors, DeleteAllFactsResponse, DeleteAllFactsResponses, DeleteFactData, DeleteFactError, DeleteFactErrors, DeleteFactResponse, DeleteFactResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, ErrorCode, ErrorInfo, ErrorResponse, ExtractSessionFactsData, ExtractSessionFactsError, ExtractSessionFactsErrors, ExtractSessionFactsResponse, ExtractSessionFactsResponses, FactInfo, FactPage, FactsDeleted, GetSessionData, GetSessionError, GetSessionErrors, GetSessionResponse, GetSessionResponses, KnowledgeCitation, KnowledgeLookupInfo, ListFactsData, ListFactsError, ListFactsErrors, ListFactsResponse, ListFactsResponses, ListRelationsData, ListRelationsError, ListRelationsErrors, ListRelationsResponse, ListRelationsResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, MessageInfo, MessageKnowledge, MessagePage, MessageToolCall, RelationInfo, RelationPage, SessionCreateRequest, SessionDetail, SessionInfo, SessionPage, ValidationIssue } from './types.gen.js';
|
|
1
|
+
export type { ClientOptions, CompactionInfo, CompactRequest, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteAllFactsData, DeleteAllFactsError, DeleteAllFactsErrors, DeleteAllFactsResponse, DeleteAllFactsResponses, DeleteFactData, DeleteFactError, DeleteFactErrors, DeleteFactResponse, DeleteFactResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, ErrorCode, ErrorInfo, ErrorResponse, ExtractSessionFactsData, ExtractSessionFactsError, ExtractSessionFactsErrors, ExtractSessionFactsResponse, ExtractSessionFactsResponses, FactInfo, FactPage, FactsDeleted, GetSessionData, GetSessionError, GetSessionErrors, GetSessionResponse, GetSessionResponses, KnowledgeCitation, KnowledgeLookupInfo, ListFactsData, ListFactsError, ListFactsErrors, ListFactsResponse, ListFactsResponses, ListRelationsData, ListRelationsError, ListRelationsErrors, ListRelationsResponse, ListRelationsResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, MessageInfo, MessageKnowledge, MessagePage, MessageSource, MessageToolCall, RelationInfo, RelationPage, SessionCreateRequest, SessionDetail, SessionInfo, SessionPage, ValidationIssue } from './types.gen.js';
|
package/dist/gen/types.gen.d.ts
CHANGED
|
@@ -89,6 +89,10 @@ export type MessageInfo = {
|
|
|
89
89
|
*/
|
|
90
90
|
mood_intensity: number | null;
|
|
91
91
|
role: 'user' | 'assistant';
|
|
92
|
+
/**
|
|
93
|
+
* On a reply: the web pages it read, with the agent's `url_context` switch on. Empty when it read none, and on the person's messages.
|
|
94
|
+
*/
|
|
95
|
+
sources: Array<MessageSource>;
|
|
92
96
|
/**
|
|
93
97
|
* On a reply: the calls of your tools it made, in order, each with its arguments and what it answered. Empty when it called none, and on the person's messages.
|
|
94
98
|
*/
|
|
@@ -118,6 +122,16 @@ export type KnowledgeCitation = {
|
|
|
118
122
|
score: number;
|
|
119
123
|
title: string;
|
|
120
124
|
};
|
|
125
|
+
/**
|
|
126
|
+
* A web page the reply read with the agent's `url_context` switch on.
|
|
127
|
+
*/
|
|
128
|
+
export type MessageSource = {
|
|
129
|
+
url: string;
|
|
130
|
+
/**
|
|
131
|
+
* Whether the page could be read. A reply may name a page it could not fetch — behind a login, refused by its site, gone — and answers without it.
|
|
132
|
+
*/
|
|
133
|
+
retrieved: boolean;
|
|
134
|
+
};
|
|
121
135
|
export type MessageToolCall = {
|
|
122
136
|
/**
|
|
123
137
|
* The call's id, `call_…`: what your server or app was sent.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@lumavox/client/web-audio`: a browser's player and recorder for a
|
|
3
|
+
* conversation, on Web Audio — an `AgentAudio` that `@lumavox/react`'s
|
|
4
|
+
* `useAgent` drives. An entry of its own, so `createClient` carries none of
|
|
5
|
+
* it (`test/dist.test.ts` holds both sizes); zero dependencies, as the rest.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const audio = createWebAudio();
|
|
9
|
+
* button.onclick = () => audio.prime(); // in the click: browsers start audio only from one
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { WebAudio } from "./web-audio.js";
|
|
13
|
+
export type { WebAudio };
|
|
14
|
+
/** A player and a recorder for one conversation. Call `prime()` from the click that starts it. */
|
|
15
|
+
export declare function createWebAudio(): WebAudio;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@lumavox/client/web-audio`: a browser's player and recorder for a
|
|
3
|
+
* conversation, on Web Audio — an `AgentAudio` that `@lumavox/react`'s
|
|
4
|
+
* `useAgent` drives. An entry of its own, so `createClient` carries none of
|
|
5
|
+
* it (`test/dist.test.ts` holds both sizes); zero dependencies, as the rest.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const audio = createWebAudio();
|
|
9
|
+
* button.onclick = () => audio.prime(); // in the click: browsers start audio only from one
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { WebAudio } from "./web-audio.js";
|
|
13
|
+
/** A player and a recorder for one conversation. Call `prime()` from the click that starts it. */
|
|
14
|
+
export function createWebAudio() {
|
|
15
|
+
return new WebAudio();
|
|
16
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playing raw PCM as it arrives, with no container and no `<audio>` element.
|
|
3
|
+
*
|
|
4
|
+
* **Why not `<audio>`.** That needs a container — a data URL with a MIME
|
|
5
|
+
* type — and the socket sends bare samples precisely to avoid one: base64 is
|
|
6
|
+
* 33% of every audio byte, and audio is most of a spoken turn. There is also
|
|
7
|
+
* nothing to point an `<audio>` element *at* until a chunk is complete, which
|
|
8
|
+
* would give back the latency the transport exists to win.
|
|
9
|
+
*
|
|
10
|
+
* So: Web Audio, one `AudioBufferSourceNode` per blob, each scheduled to start
|
|
11
|
+
* exactly where the last one ended. Three things make that sound continuous
|
|
12
|
+
* rather than like a stutter of clips:
|
|
13
|
+
*
|
|
14
|
+
* - **The schedule is kept in the context's own clock**, never in wall time.
|
|
15
|
+
* `playhead` is an absolute `AudioContext.currentTime`, so scheduling is
|
|
16
|
+
* sample-accurate and drift-free even if the tab is busy.
|
|
17
|
+
* - **A blob arriving late restarts from `currentTime`**, not from a playhead
|
|
18
|
+
* in the past — otherwise it is scheduled to have already happened and is
|
|
19
|
+
* simply dropped, which is heard as a missing sentence.
|
|
20
|
+
* - **A tiny lead-in** (`SCHEDULE_AHEAD`) absorbs the jitter between a frame
|
|
21
|
+
* arriving and it being decoded, at the cost of a few milliseconds.
|
|
22
|
+
*
|
|
23
|
+
* **The context runs at the device's own rate**; each buffer is made at the
|
|
24
|
+
* stream's (24 kHz unless `audio.start` says otherwise) and the browser
|
|
25
|
+
* resamples it as it plays — every browser does, as Web Audio requires.
|
|
26
|
+
* Firefox's echo canceller hears only a context at the device's rate, which
|
|
27
|
+
* hands-free will need; until the console's code became this module (client
|
|
28
|
+
* 0.8.0) the context was pinned to the stream's rate and rebuilt when it
|
|
29
|
+
* changed.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here decodes: the format is 16-bit signed little-endian, so it is
|
|
32
|
+
* an `Int16Array` read straight into a `Float32Array`.
|
|
33
|
+
*/
|
|
34
|
+
export interface PcmPlayerOptions {
|
|
35
|
+
/** Hz of the PCM pushed, until `setSampleRate`: the socket's default. */
|
|
36
|
+
sampleRate?: number;
|
|
37
|
+
channels?: number;
|
|
38
|
+
/** Called when the queue drains — every scheduled source has finished. */
|
|
39
|
+
onIdle?: () => void;
|
|
40
|
+
}
|
|
41
|
+
export declare class PcmPlayer {
|
|
42
|
+
private context;
|
|
43
|
+
private gain;
|
|
44
|
+
private playhead;
|
|
45
|
+
/** A trailing byte from the previous frame, when one was split. */
|
|
46
|
+
private remainder;
|
|
47
|
+
private scheduled;
|
|
48
|
+
/** Bumped by `stop()`, so a source cut off by it does not count itself out of
|
|
49
|
+
* a queue that has already been zeroed — `scheduled` went negative after a
|
|
50
|
+
* barge-in until console 0.197.0, and `playing` lied from then on. */
|
|
51
|
+
private generation;
|
|
52
|
+
private sampleRate;
|
|
53
|
+
private readonly channels;
|
|
54
|
+
private readonly onIdle?;
|
|
55
|
+
constructor(options?: PcmPlayerOptions);
|
|
56
|
+
/**
|
|
57
|
+
* The rate of the PCM pushed from here on: each `audio.start` carries its
|
|
58
|
+
* window's own, since an agent's voice may be edited mid-conversation.
|
|
59
|
+
* What is queued plays on at its own rate — every buffer carries it — so
|
|
60
|
+
* nothing is dropped and the context stays.
|
|
61
|
+
*/
|
|
62
|
+
setSampleRate(rate: number): void;
|
|
63
|
+
/**
|
|
64
|
+
* Create the context, or resume one the browser suspended.
|
|
65
|
+
*
|
|
66
|
+
* **Must be called from a user gesture the first time.** Every browser
|
|
67
|
+
* suspends a context created outside one, and a suspended context accepts
|
|
68
|
+
* every scheduling call silently — so the symptom is a conversation that
|
|
69
|
+
* looks like it is speaking and makes no sound.
|
|
70
|
+
*/
|
|
71
|
+
resume(): Promise<void>;
|
|
72
|
+
/** Whether anything is scheduled and still to play. */
|
|
73
|
+
get playing(): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Queue one blob of PCM.
|
|
76
|
+
*
|
|
77
|
+
* Ignored before {@link resume} — a caller that pushes audio into a player
|
|
78
|
+
* nobody unlocked has a gesture problem, and buffering it would hide that
|
|
79
|
+
* until the sound arrived minutes later in one burst.
|
|
80
|
+
*/
|
|
81
|
+
push(pcm: ArrayBuffer): void;
|
|
82
|
+
/**
|
|
83
|
+
* Stop everything now — barge-in, or the turn being cancelled.
|
|
84
|
+
*
|
|
85
|
+
* Disconnecting the gain node rather than stopping each source: sources are
|
|
86
|
+
* fire-and-forget once scheduled and holding references to them purely to
|
|
87
|
+
* stop them is a leak waiting to happen. A fresh node is built on the next
|
|
88
|
+
* push.
|
|
89
|
+
*/
|
|
90
|
+
stop(): void;
|
|
91
|
+
/** Release the audio device. Safe to call more than once. */
|
|
92
|
+
close(): Promise<void>;
|
|
93
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playing raw PCM as it arrives, with no container and no `<audio>` element.
|
|
3
|
+
*
|
|
4
|
+
* **Why not `<audio>`.** That needs a container — a data URL with a MIME
|
|
5
|
+
* type — and the socket sends bare samples precisely to avoid one: base64 is
|
|
6
|
+
* 33% of every audio byte, and audio is most of a spoken turn. There is also
|
|
7
|
+
* nothing to point an `<audio>` element *at* until a chunk is complete, which
|
|
8
|
+
* would give back the latency the transport exists to win.
|
|
9
|
+
*
|
|
10
|
+
* So: Web Audio, one `AudioBufferSourceNode` per blob, each scheduled to start
|
|
11
|
+
* exactly where the last one ended. Three things make that sound continuous
|
|
12
|
+
* rather than like a stutter of clips:
|
|
13
|
+
*
|
|
14
|
+
* - **The schedule is kept in the context's own clock**, never in wall time.
|
|
15
|
+
* `playhead` is an absolute `AudioContext.currentTime`, so scheduling is
|
|
16
|
+
* sample-accurate and drift-free even if the tab is busy.
|
|
17
|
+
* - **A blob arriving late restarts from `currentTime`**, not from a playhead
|
|
18
|
+
* in the past — otherwise it is scheduled to have already happened and is
|
|
19
|
+
* simply dropped, which is heard as a missing sentence.
|
|
20
|
+
* - **A tiny lead-in** (`SCHEDULE_AHEAD`) absorbs the jitter between a frame
|
|
21
|
+
* arriving and it being decoded, at the cost of a few milliseconds.
|
|
22
|
+
*
|
|
23
|
+
* **The context runs at the device's own rate**; each buffer is made at the
|
|
24
|
+
* stream's (24 kHz unless `audio.start` says otherwise) and the browser
|
|
25
|
+
* resamples it as it plays — every browser does, as Web Audio requires.
|
|
26
|
+
* Firefox's echo canceller hears only a context at the device's rate, which
|
|
27
|
+
* hands-free will need; until the console's code became this module (client
|
|
28
|
+
* 0.8.0) the context was pinned to the stream's rate and rebuilt when it
|
|
29
|
+
* changed.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here decodes: the format is 16-bit signed little-endian, so it is
|
|
32
|
+
* an `Int16Array` read straight into a `Float32Array`.
|
|
33
|
+
*/
|
|
34
|
+
import { DEFAULT_SAMPLE_RATE } from "../protocol.gen.js";
|
|
35
|
+
/** How far ahead of `currentTime` a freshly-started run begins. Enough to
|
|
36
|
+
* absorb frame jitter, short enough not to be perceived as delay. */
|
|
37
|
+
const SCHEDULE_AHEAD = 0.06;
|
|
38
|
+
/** Divisor from 16-bit signed to the -1..1 floats Web Audio wants. 32768,
|
|
39
|
+
* because the negative range is one larger than the positive one. */
|
|
40
|
+
const FULL_SCALE = 32768;
|
|
41
|
+
export class PcmPlayer {
|
|
42
|
+
constructor(options = {}) {
|
|
43
|
+
this.context = null;
|
|
44
|
+
this.gain = null;
|
|
45
|
+
this.playhead = 0;
|
|
46
|
+
/** A trailing byte from the previous frame, when one was split. */
|
|
47
|
+
this.remainder = new Uint8Array(0);
|
|
48
|
+
this.scheduled = 0;
|
|
49
|
+
/** Bumped by `stop()`, so a source cut off by it does not count itself out of
|
|
50
|
+
* a queue that has already been zeroed — `scheduled` went negative after a
|
|
51
|
+
* barge-in until console 0.197.0, and `playing` lied from then on. */
|
|
52
|
+
this.generation = 0;
|
|
53
|
+
// The edge's default (the vocabulary's), until an `audio.start` says
|
|
54
|
+
// otherwise.
|
|
55
|
+
this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
56
|
+
this.channels = options.channels ?? 1;
|
|
57
|
+
this.onIdle = options.onIdle;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The rate of the PCM pushed from here on: each `audio.start` carries its
|
|
61
|
+
* window's own, since an agent's voice may be edited mid-conversation.
|
|
62
|
+
* What is queued plays on at its own rate — every buffer carries it — so
|
|
63
|
+
* nothing is dropped and the context stays.
|
|
64
|
+
*/
|
|
65
|
+
setSampleRate(rate) {
|
|
66
|
+
if (!Number.isFinite(rate) || rate <= 0)
|
|
67
|
+
return;
|
|
68
|
+
this.sampleRate = rate;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Create the context, or resume one the browser suspended.
|
|
72
|
+
*
|
|
73
|
+
* **Must be called from a user gesture the first time.** Every browser
|
|
74
|
+
* suspends a context created outside one, and a suspended context accepts
|
|
75
|
+
* every scheduling call silently — so the symptom is a conversation that
|
|
76
|
+
* looks like it is speaking and makes no sound.
|
|
77
|
+
*/
|
|
78
|
+
async resume() {
|
|
79
|
+
if (!this.context) {
|
|
80
|
+
this.context = new AudioContext();
|
|
81
|
+
this.gain = this.context.createGain();
|
|
82
|
+
this.gain.connect(this.context.destination);
|
|
83
|
+
}
|
|
84
|
+
if (this.context.state === "suspended")
|
|
85
|
+
await this.context.resume();
|
|
86
|
+
}
|
|
87
|
+
/** Whether anything is scheduled and still to play. */
|
|
88
|
+
get playing() {
|
|
89
|
+
return this.scheduled > 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Queue one blob of PCM.
|
|
93
|
+
*
|
|
94
|
+
* Ignored before {@link resume} — a caller that pushes audio into a player
|
|
95
|
+
* nobody unlocked has a gesture problem, and buffering it would hide that
|
|
96
|
+
* until the sound arrived minutes later in one burst.
|
|
97
|
+
*/
|
|
98
|
+
push(pcm) {
|
|
99
|
+
const context = this.context;
|
|
100
|
+
if (!context)
|
|
101
|
+
return;
|
|
102
|
+
// **A split sample is carried, never dropped.** The edge sends whole
|
|
103
|
+
// samples per frame (`apps/edge/src/voice/tts.ts` holds an odd byte for
|
|
104
|
+
// the next blob), so this should never fire — but truncating instead is
|
|
105
|
+
// the bug that made ElevenLabs playback white noise, and it is worth being
|
|
106
|
+
// unable to reintroduce. Dropping the odd byte does not lose one sample,
|
|
107
|
+
// it shifts every byte after it by one and turns the rest of the frame
|
|
108
|
+
// into noise until something realigns it.
|
|
109
|
+
let bytes = new Uint8Array(pcm);
|
|
110
|
+
if (this.remainder.length) {
|
|
111
|
+
const joined = new Uint8Array(this.remainder.length + bytes.length);
|
|
112
|
+
joined.set(this.remainder, 0);
|
|
113
|
+
joined.set(bytes, this.remainder.length);
|
|
114
|
+
bytes = joined;
|
|
115
|
+
}
|
|
116
|
+
const usable = bytes.length - (bytes.length % 2);
|
|
117
|
+
this.remainder = bytes.slice(usable);
|
|
118
|
+
if (usable < 2)
|
|
119
|
+
return;
|
|
120
|
+
// Copied into its own buffer rather than viewed in place: `Int16Array`
|
|
121
|
+
// needs a 2-byte-aligned offset, and a frame joined to a carried byte has
|
|
122
|
+
// no such promise.
|
|
123
|
+
const samples = new Int16Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + usable));
|
|
124
|
+
// At the stream's rate, not the context's: the browser resamples it.
|
|
125
|
+
const buffer = context.createBuffer(this.channels, samples.length / this.channels, this.sampleRate);
|
|
126
|
+
for (let channel = 0; channel < this.channels; channel += 1) {
|
|
127
|
+
const target = buffer.getChannelData(channel);
|
|
128
|
+
for (let i = 0; i < target.length; i += 1)
|
|
129
|
+
target[i] = samples[i * this.channels + channel] / FULL_SCALE;
|
|
130
|
+
}
|
|
131
|
+
const source = context.createBufferSource();
|
|
132
|
+
source.buffer = buffer;
|
|
133
|
+
source.connect(this.gain);
|
|
134
|
+
// **The branch that keeps it gapless.** While audio is still queued, the
|
|
135
|
+
// next blob starts exactly where the last one ends; once the queue has run
|
|
136
|
+
// dry, the playhead is in the past and scheduling against it would mean
|
|
137
|
+
// "start before now", which the API resolves as "start immediately" for the
|
|
138
|
+
// first blob and drops for every one after it.
|
|
139
|
+
const now = context.currentTime;
|
|
140
|
+
const startAt = this.playhead > now ? this.playhead : now + SCHEDULE_AHEAD;
|
|
141
|
+
source.start(startAt);
|
|
142
|
+
this.playhead = startAt + buffer.duration;
|
|
143
|
+
this.scheduled += 1;
|
|
144
|
+
const generation = this.generation;
|
|
145
|
+
source.onended = () => {
|
|
146
|
+
if (generation !== this.generation)
|
|
147
|
+
return;
|
|
148
|
+
this.scheduled -= 1;
|
|
149
|
+
if (this.scheduled === 0)
|
|
150
|
+
this.onIdle?.();
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Stop everything now — barge-in, or the turn being cancelled.
|
|
155
|
+
*
|
|
156
|
+
* Disconnecting the gain node rather than stopping each source: sources are
|
|
157
|
+
* fire-and-forget once scheduled and holding references to them purely to
|
|
158
|
+
* stop them is a leak waiting to happen. A fresh node is built on the next
|
|
159
|
+
* push.
|
|
160
|
+
*/
|
|
161
|
+
stop() {
|
|
162
|
+
if (!this.context || !this.gain)
|
|
163
|
+
return;
|
|
164
|
+
this.gain.disconnect();
|
|
165
|
+
this.gain = this.context.createGain();
|
|
166
|
+
this.gain.connect(this.context.destination);
|
|
167
|
+
this.playhead = 0;
|
|
168
|
+
const wasPlaying = this.scheduled > 0;
|
|
169
|
+
this.scheduled = 0;
|
|
170
|
+
this.generation += 1;
|
|
171
|
+
// A half sample belonging to audio that will never play now.
|
|
172
|
+
this.remainder = new Uint8Array(0);
|
|
173
|
+
if (wasPlaying)
|
|
174
|
+
this.onIdle?.();
|
|
175
|
+
}
|
|
176
|
+
/** Release the audio device. Safe to call more than once. */
|
|
177
|
+
async close() {
|
|
178
|
+
const context = this.context;
|
|
179
|
+
this.context = null;
|
|
180
|
+
this.gain = null;
|
|
181
|
+
this.playhead = 0;
|
|
182
|
+
this.scheduled = 0;
|
|
183
|
+
this.remainder = new Uint8Array(0);
|
|
184
|
+
if (context && context.state !== "closed")
|
|
185
|
+
await context.close();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The microphone, as the same raw PCM this socket already plays.
|
|
3
|
+
*
|
|
4
|
+
* The mirror of `player.ts`: 16-bit signed little-endian mono, at 24 kHz —
|
|
5
|
+
* the rate the socket's audio defaults to (`DEFAULT_SAMPLE_RATE`), and one
|
|
6
|
+
* of `RECORDING_RATES`.
|
|
7
|
+
*
|
|
8
|
+
* **No `MediaRecorder`, and that is the whole design.** It produces a
|
|
9
|
+
* *container* — webm/opus, whatever the browser feels like — which the socket
|
|
10
|
+
* cannot take: binary frames there are bare samples with no header, and the
|
|
11
|
+
* server would have to decode a container to find out what it was given. Web
|
|
12
|
+
* Audio hands over float samples instead, and converting those to 16-bit
|
|
13
|
+
* integers is four lines.
|
|
14
|
+
*
|
|
15
|
+
* Three things make it work in practice:
|
|
16
|
+
*
|
|
17
|
+
* - **This context is opened at 24 kHz** rather than resampled after the
|
|
18
|
+
* fact. Browsers accept a `sampleRate` on the constructor and do the
|
|
19
|
+
* conversion in the audio thread, which is both better and free. (The
|
|
20
|
+
* player's context is the device's own: `player.ts` says why.)
|
|
21
|
+
* - **`AudioWorklet` with a `ScriptProcessor` fallback.** The worklet runs off
|
|
22
|
+
* the main thread, so a busy React render cannot drop samples — which is
|
|
23
|
+
* heard as a clipped word rather than as a stutter, and is exactly the kind
|
|
24
|
+
* of fault that would be blamed on the recogniser.
|
|
25
|
+
* - **The track is stopped on `stop()`**, not merely disconnected. A live
|
|
26
|
+
* `MediaStreamTrack` leaves the browser's recording indicator on, and a
|
|
27
|
+
* microphone that looks like it is still listening is a trust problem
|
|
28
|
+
* whatever the code is doing.
|
|
29
|
+
*
|
|
30
|
+
* Loaded on the first press (`web-audio.ts`), so a page that never records
|
|
31
|
+
* never parses it.
|
|
32
|
+
*/
|
|
33
|
+
/** The rate this records at, whatever is asked: the socket's default. */
|
|
34
|
+
export declare const RECORD_SAMPLE_RATE = 24000;
|
|
35
|
+
/** Samples per delivered chunk — about 85 ms at 24 kHz. Small enough that the
|
|
36
|
+
* upload overlaps the talking, large enough not to be one WebSocket frame per
|
|
37
|
+
* animation tick.
|
|
38
|
+
*
|
|
39
|
+
* **Applied to the worklet path too.** The worklet posts every 128-sample
|
|
40
|
+
* render quantum; one WebSocket frame a post is about 188 frames a second of
|
|
41
|
+
* 256 bytes, each a TLS record and a message the end user DO wakes for —
|
|
42
|
+
* sixteen times as many as at this size. Batched here, on the main thread,
|
|
43
|
+
* where the tail can be flushed synchronously when the button is released. */
|
|
44
|
+
export declare const FRAME_SAMPLES = 2048;
|
|
45
|
+
export interface PcmRecorderOptions {
|
|
46
|
+
/** Called with each block of samples, already 16-bit little-endian. */
|
|
47
|
+
onChunk: (pcm: ArrayBuffer) => void;
|
|
48
|
+
sampleRate?: number;
|
|
49
|
+
}
|
|
50
|
+
/** Float samples to 16-bit signed little-endian, which is what the wire wants. */
|
|
51
|
+
export declare function toPcm16(samples: Float32Array): ArrayBuffer;
|
|
52
|
+
export declare class PcmRecorder {
|
|
53
|
+
/** Whether chunks may still be delivered.
|
|
54
|
+
*
|
|
55
|
+
* **Cleared synchronously by `stop()`, and checked before every delivery.**
|
|
56
|
+
* Disconnecting a node does not un-post the messages it has already sent:
|
|
57
|
+
* the worklet runs on the audio thread and its `port.postMessage` calls
|
|
58
|
+
* arrive on the main thread as ordinary tasks, so a handful land *after*
|
|
59
|
+
* `stop()` has returned.
|
|
60
|
+
*
|
|
61
|
+
* That is not a tidiness problem. The caller sends each chunk into an open
|
|
62
|
+
* `user_audio` window and closes that window immediately after stopping —
|
|
63
|
+
* so a late chunk is binary audio arriving with no window open, which the
|
|
64
|
+
* edge answers by closing the connection. That is how it shows: hold,
|
|
65
|
+
* release, hold, release, and the socket dies with
|
|
66
|
+
* "Binary audio arrived with no user_audio.start before it."
|
|
67
|
+
*/
|
|
68
|
+
private live;
|
|
69
|
+
private context;
|
|
70
|
+
private stream;
|
|
71
|
+
private node;
|
|
72
|
+
private source;
|
|
73
|
+
private readonly onChunk;
|
|
74
|
+
private readonly sampleRate;
|
|
75
|
+
/** Samples not yet delivered — see `FRAME_SAMPLES`. */
|
|
76
|
+
private pending;
|
|
77
|
+
private pendingSamples;
|
|
78
|
+
constructor(options: PcmRecorderOptions);
|
|
79
|
+
/** True once `start()` has a live microphone and has not been stopped. */
|
|
80
|
+
get recording(): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Hz the delivered samples actually are.
|
|
83
|
+
*
|
|
84
|
+
* The context is *asked* for `sampleRate`; browsers honour it or throw, and
|
|
85
|
+
* this reads back what was granted rather than repeating the request. It is
|
|
86
|
+
* what a caller must declare on `user_audio.start` — a declared rate the
|
|
87
|
+
* samples are not is the wrong pitch and a wrong bill, behind a 200.
|
|
88
|
+
*/
|
|
89
|
+
get rate(): number;
|
|
90
|
+
/**
|
|
91
|
+
* Ask for the microphone and begin delivering samples.
|
|
92
|
+
*
|
|
93
|
+
* Rejects if permission is refused, which is the caller's to report — a mic
|
|
94
|
+
* button that silently does nothing after a denied prompt is the worst of the
|
|
95
|
+
* available outcomes.
|
|
96
|
+
*/
|
|
97
|
+
start(): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Stop, release the microphone, and turn the browser's indicator off.
|
|
100
|
+
*
|
|
101
|
+
* Returns the **tail**: samples batched and not yet delivered when the button
|
|
102
|
+
* was released — up to one frame, ~85 ms, the end of the last word. Returned
|
|
103
|
+
* rather than delivered through `onChunk`, because the caller has by now
|
|
104
|
+
* stopped accepting chunks (`stop()` is what it calls to stop them) and
|
|
105
|
+
* decides for itself whether the tail goes on the wire before `user_audio.end`.
|
|
106
|
+
* `null` when there is nothing held.
|
|
107
|
+
*/
|
|
108
|
+
stop(): ArrayBuffer | null;
|
|
109
|
+
/** Batch a block of samples, delivering whole frames as they fill. */
|
|
110
|
+
private deliver;
|
|
111
|
+
/** Up to `count` pending samples (all of them by default), as one block. */
|
|
112
|
+
private takePending;
|
|
113
|
+
private tap;
|
|
114
|
+
}
|
|
@@ -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,40 @@
|
|
|
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
|
+
*/
|
|
18
|
+
import type { AgentAudio } from "../audio.js";
|
|
19
|
+
export declare class WebAudio implements AgentAudio {
|
|
20
|
+
private player;
|
|
21
|
+
private recorder;
|
|
22
|
+
private onPcm;
|
|
23
|
+
private readonly drained;
|
|
24
|
+
/** Make the player and unlock it. **Call it from the click**, before any `await`. */
|
|
25
|
+
prime(): Promise<void>;
|
|
26
|
+
play(pcm: ArrayBuffer, frame: {
|
|
27
|
+
sample_rate: number;
|
|
28
|
+
}): void;
|
|
29
|
+
stop(): void;
|
|
30
|
+
onDrained(fn: () => void): () => void;
|
|
31
|
+
/** At the recorder's own rate, 24 kHz, whatever is asked. */
|
|
32
|
+
startCapture(onPcm: (pcm: ArrayBuffer) => void, _options?: {
|
|
33
|
+
rate?: number;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
rate: number;
|
|
36
|
+
}>;
|
|
37
|
+
stopCapture(): Promise<void>;
|
|
38
|
+
/** The conversation is over: the player's context goes too. */
|
|
39
|
+
dispose(): void;
|
|
40
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { PcmPlayer } from "./player.js";
|
|
2
|
+
export class WebAudio {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.player = null;
|
|
5
|
+
this.recorder = null;
|
|
6
|
+
this.onPcm = null;
|
|
7
|
+
this.drained = new Set();
|
|
8
|
+
}
|
|
9
|
+
/** Make the player and unlock it. **Call it from the click**, before any `await`. */
|
|
10
|
+
prime() {
|
|
11
|
+
this.player ?? (this.player = new PcmPlayer({
|
|
12
|
+
onIdle: () => {
|
|
13
|
+
for (const fn of this.drained)
|
|
14
|
+
fn();
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
return this.player.resume();
|
|
18
|
+
}
|
|
19
|
+
play(pcm, frame) {
|
|
20
|
+
if (!this.player)
|
|
21
|
+
void this.prime();
|
|
22
|
+
const player = this.player;
|
|
23
|
+
player.setSampleRate(frame.sample_rate);
|
|
24
|
+
player.push(pcm);
|
|
25
|
+
}
|
|
26
|
+
stop() {
|
|
27
|
+
this.player?.stop();
|
|
28
|
+
}
|
|
29
|
+
onDrained(fn) {
|
|
30
|
+
this.drained.add(fn);
|
|
31
|
+
return () => {
|
|
32
|
+
this.drained.delete(fn);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** At the recorder's own rate, 24 kHz, whatever is asked. */
|
|
36
|
+
async startCapture(onPcm, _options) {
|
|
37
|
+
const { PcmRecorder } = await import("./recorder.js");
|
|
38
|
+
const mic = new PcmRecorder({ onChunk: (pcm) => this.onPcm?.(pcm) });
|
|
39
|
+
this.onPcm = onPcm;
|
|
40
|
+
this.recorder = mic;
|
|
41
|
+
try {
|
|
42
|
+
await mic.start();
|
|
43
|
+
}
|
|
44
|
+
catch (failure) {
|
|
45
|
+
if (this.recorder === mic)
|
|
46
|
+
this.recorder = null;
|
|
47
|
+
throw failure;
|
|
48
|
+
}
|
|
49
|
+
return { rate: mic.rate };
|
|
50
|
+
}
|
|
51
|
+
async stopCapture() {
|
|
52
|
+
const mic = this.recorder;
|
|
53
|
+
const deliver = this.onPcm;
|
|
54
|
+
this.recorder = null;
|
|
55
|
+
this.onPcm = null;
|
|
56
|
+
const tail = mic?.stop();
|
|
57
|
+
if (tail)
|
|
58
|
+
deliver?.(tail);
|
|
59
|
+
}
|
|
60
|
+
/** The conversation is over: the player's context goes too. */
|
|
61
|
+
dispose() {
|
|
62
|
+
void this.stopCapture();
|
|
63
|
+
void this.player?.close();
|
|
64
|
+
this.player = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumavox/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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
|
}
|