@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
package/dist/protocol.gen.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare const PROTOCOL = "ino.v2";
|
|
|
4
4
|
export declare const CLIENT_VERBS: readonly ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed", "tool.result"];
|
|
5
5
|
export type ClientVerb = (typeof CLIENT_VERBS)[number];
|
|
6
6
|
/** Every event the edge sends. */
|
|
7
|
-
export declare const SERVER_EVENTS: readonly ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
|
|
7
|
+
export declare const SERVER_EVENTS: readonly ["ready", "turn.start", "heard", "heard.partial", "barge_in", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
|
|
8
8
|
export type ServerEventName = (typeof SERVER_EVENTS)[number];
|
|
9
9
|
/** The events that end a turn: exactly one of them ends every turn. */
|
|
10
10
|
export declare const TERMINAL_EVENTS: readonly ["turn.done", "interrupted"];
|
package/dist/protocol.gen.js
CHANGED
|
@@ -7,7 +7,7 @@ export const PROTOCOL = "ino.v2";
|
|
|
7
7
|
/** Every frame a client may send; the edge closes on any other (4009). */
|
|
8
8
|
export const CLIENT_VERBS = ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed", "tool.result"];
|
|
9
9
|
/** Every event the edge sends. */
|
|
10
|
-
export const SERVER_EVENTS = ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
|
|
10
|
+
export const SERVER_EVENTS = ["ready", "turn.start", "heard", "heard.partial", "barge_in", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
|
|
11
11
|
/** The events that end a turn: exactly one of them ends every turn. */
|
|
12
12
|
export const TERMINAL_EVENTS = ["turn.done", "interrupted"];
|
|
13
13
|
/** A `ping` of exactly this text is answered by the edge's runtime with `KEEPALIVE_PONG`, which has no `seq`. */
|
|
@@ -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,100 @@
|
|
|
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
|
+
/** The reply's loudness, 0 to 1: kept across `stop()`'s new gain node. */
|
|
54
|
+
private volume;
|
|
55
|
+
private readonly channels;
|
|
56
|
+
private readonly onIdle?;
|
|
57
|
+
constructor(options?: PcmPlayerOptions);
|
|
58
|
+
/**
|
|
59
|
+
* The rate of the PCM pushed from here on: each `audio.start` carries its
|
|
60
|
+
* window's own, since an agent's voice may be edited mid-conversation.
|
|
61
|
+
* What is queued plays on at its own rate — every buffer carries it — so
|
|
62
|
+
* nothing is dropped and the context stays.
|
|
63
|
+
*/
|
|
64
|
+
setSampleRate(rate: number): void;
|
|
65
|
+
/**
|
|
66
|
+
* Create the context, or resume one the browser suspended.
|
|
67
|
+
*
|
|
68
|
+
* **Must be called from a user gesture the first time.** Every browser
|
|
69
|
+
* suspends a context created outside one, and a suspended context accepts
|
|
70
|
+
* every scheduling call silently — so the symptom is a conversation that
|
|
71
|
+
* looks like it is speaking and makes no sound.
|
|
72
|
+
*/
|
|
73
|
+
resume(): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Play quieter, or as before: hands-free lowers the reply while the
|
|
76
|
+
* person may be talking over it. A 15 ms glide, so it does not click.
|
|
77
|
+
*/
|
|
78
|
+
setVolume(level: number): void;
|
|
79
|
+
/** Whether anything is scheduled and still to play. */
|
|
80
|
+
get playing(): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Queue one blob of PCM.
|
|
83
|
+
*
|
|
84
|
+
* Ignored before {@link resume} — a caller that pushes audio into a player
|
|
85
|
+
* nobody unlocked has a gesture problem, and buffering it would hide that
|
|
86
|
+
* until the sound arrived minutes later in one burst.
|
|
87
|
+
*/
|
|
88
|
+
push(pcm: ArrayBuffer): void;
|
|
89
|
+
/**
|
|
90
|
+
* Stop everything now — barge-in, or the turn being cancelled.
|
|
91
|
+
*
|
|
92
|
+
* Disconnecting the gain node rather than stopping each source: sources are
|
|
93
|
+
* fire-and-forget once scheduled and holding references to them purely to
|
|
94
|
+
* stop them is a leak waiting to happen. A fresh node is built on the next
|
|
95
|
+
* push.
|
|
96
|
+
*/
|
|
97
|
+
stop(): void;
|
|
98
|
+
/** Release the audio device. Safe to call more than once. */
|
|
99
|
+
close(): Promise<void>;
|
|
100
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
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 reply's loudness, 0 to 1: kept across `stop()`'s new gain node. */
|
|
54
|
+
this.volume = 1;
|
|
55
|
+
// The edge's default (the vocabulary's), until an `audio.start` says
|
|
56
|
+
// otherwise.
|
|
57
|
+
this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
58
|
+
this.channels = options.channels ?? 1;
|
|
59
|
+
this.onIdle = options.onIdle;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The rate of the PCM pushed from here on: each `audio.start` carries its
|
|
63
|
+
* window's own, since an agent's voice may be edited mid-conversation.
|
|
64
|
+
* What is queued plays on at its own rate — every buffer carries it — so
|
|
65
|
+
* nothing is dropped and the context stays.
|
|
66
|
+
*/
|
|
67
|
+
setSampleRate(rate) {
|
|
68
|
+
if (!Number.isFinite(rate) || rate <= 0)
|
|
69
|
+
return;
|
|
70
|
+
this.sampleRate = rate;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create the context, or resume one the browser suspended.
|
|
74
|
+
*
|
|
75
|
+
* **Must be called from a user gesture the first time.** Every browser
|
|
76
|
+
* suspends a context created outside one, and a suspended context accepts
|
|
77
|
+
* every scheduling call silently — so the symptom is a conversation that
|
|
78
|
+
* looks like it is speaking and makes no sound.
|
|
79
|
+
*/
|
|
80
|
+
async resume() {
|
|
81
|
+
if (!this.context) {
|
|
82
|
+
this.context = new AudioContext();
|
|
83
|
+
this.gain = this.context.createGain();
|
|
84
|
+
this.gain.gain.value = this.volume;
|
|
85
|
+
this.gain.connect(this.context.destination);
|
|
86
|
+
}
|
|
87
|
+
if (this.context.state === "suspended")
|
|
88
|
+
await this.context.resume();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Play quieter, or as before: hands-free lowers the reply while the
|
|
92
|
+
* person may be talking over it. A 15 ms glide, so it does not click.
|
|
93
|
+
*/
|
|
94
|
+
setVolume(level) {
|
|
95
|
+
this.volume = level;
|
|
96
|
+
if (this.context && this.gain) {
|
|
97
|
+
this.gain.gain.setTargetAtTime(level, this.context.currentTime, 0.015);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Whether anything is scheduled and still to play. */
|
|
101
|
+
get playing() {
|
|
102
|
+
return this.scheduled > 0;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Queue one blob of PCM.
|
|
106
|
+
*
|
|
107
|
+
* Ignored before {@link resume} — a caller that pushes audio into a player
|
|
108
|
+
* nobody unlocked has a gesture problem, and buffering it would hide that
|
|
109
|
+
* until the sound arrived minutes later in one burst.
|
|
110
|
+
*/
|
|
111
|
+
push(pcm) {
|
|
112
|
+
const context = this.context;
|
|
113
|
+
if (!context)
|
|
114
|
+
return;
|
|
115
|
+
// **A split sample is carried, never dropped.** The edge sends whole
|
|
116
|
+
// samples per frame (`apps/edge/src/voice/tts.ts` holds an odd byte for
|
|
117
|
+
// the next blob), so this should never fire — but truncating instead is
|
|
118
|
+
// the bug that made ElevenLabs playback white noise, and it is worth being
|
|
119
|
+
// unable to reintroduce. Dropping the odd byte does not lose one sample,
|
|
120
|
+
// it shifts every byte after it by one and turns the rest of the frame
|
|
121
|
+
// into noise until something realigns it.
|
|
122
|
+
let bytes = new Uint8Array(pcm);
|
|
123
|
+
if (this.remainder.length) {
|
|
124
|
+
const joined = new Uint8Array(this.remainder.length + bytes.length);
|
|
125
|
+
joined.set(this.remainder, 0);
|
|
126
|
+
joined.set(bytes, this.remainder.length);
|
|
127
|
+
bytes = joined;
|
|
128
|
+
}
|
|
129
|
+
const usable = bytes.length - (bytes.length % 2);
|
|
130
|
+
this.remainder = bytes.slice(usable);
|
|
131
|
+
if (usable < 2)
|
|
132
|
+
return;
|
|
133
|
+
// Copied into its own buffer rather than viewed in place: `Int16Array`
|
|
134
|
+
// needs a 2-byte-aligned offset, and a frame joined to a carried byte has
|
|
135
|
+
// no such promise.
|
|
136
|
+
const samples = new Int16Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + usable));
|
|
137
|
+
// At the stream's rate, not the context's: the browser resamples it.
|
|
138
|
+
const buffer = context.createBuffer(this.channels, samples.length / this.channels, this.sampleRate);
|
|
139
|
+
for (let channel = 0; channel < this.channels; channel += 1) {
|
|
140
|
+
const target = buffer.getChannelData(channel);
|
|
141
|
+
for (let i = 0; i < target.length; i += 1)
|
|
142
|
+
target[i] = samples[i * this.channels + channel] / FULL_SCALE;
|
|
143
|
+
}
|
|
144
|
+
const source = context.createBufferSource();
|
|
145
|
+
source.buffer = buffer;
|
|
146
|
+
source.connect(this.gain);
|
|
147
|
+
// **The branch that keeps it gapless.** While audio is still queued, the
|
|
148
|
+
// next blob starts exactly where the last one ends; once the queue has run
|
|
149
|
+
// dry, the playhead is in the past and scheduling against it would mean
|
|
150
|
+
// "start before now", which the API resolves as "start immediately" for the
|
|
151
|
+
// first blob and drops for every one after it.
|
|
152
|
+
const now = context.currentTime;
|
|
153
|
+
const startAt = this.playhead > now ? this.playhead : now + SCHEDULE_AHEAD;
|
|
154
|
+
source.start(startAt);
|
|
155
|
+
this.playhead = startAt + buffer.duration;
|
|
156
|
+
this.scheduled += 1;
|
|
157
|
+
const generation = this.generation;
|
|
158
|
+
source.onended = () => {
|
|
159
|
+
if (generation !== this.generation)
|
|
160
|
+
return;
|
|
161
|
+
this.scheduled -= 1;
|
|
162
|
+
if (this.scheduled === 0)
|
|
163
|
+
this.onIdle?.();
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Stop everything now — barge-in, or the turn being cancelled.
|
|
168
|
+
*
|
|
169
|
+
* Disconnecting the gain node rather than stopping each source: sources are
|
|
170
|
+
* fire-and-forget once scheduled and holding references to them purely to
|
|
171
|
+
* stop them is a leak waiting to happen. A fresh node is built on the next
|
|
172
|
+
* push.
|
|
173
|
+
*/
|
|
174
|
+
stop() {
|
|
175
|
+
if (!this.context || !this.gain)
|
|
176
|
+
return;
|
|
177
|
+
this.gain.disconnect();
|
|
178
|
+
this.gain = this.context.createGain();
|
|
179
|
+
this.gain.gain.value = this.volume;
|
|
180
|
+
this.gain.connect(this.context.destination);
|
|
181
|
+
this.playhead = 0;
|
|
182
|
+
const wasPlaying = this.scheduled > 0;
|
|
183
|
+
this.scheduled = 0;
|
|
184
|
+
this.generation += 1;
|
|
185
|
+
// A half sample belonging to audio that will never play now.
|
|
186
|
+
this.remainder = new Uint8Array(0);
|
|
187
|
+
if (wasPlaying)
|
|
188
|
+
this.onIdle?.();
|
|
189
|
+
}
|
|
190
|
+
/** Release the audio device. Safe to call more than once. */
|
|
191
|
+
async close() {
|
|
192
|
+
const context = this.context;
|
|
193
|
+
this.context = null;
|
|
194
|
+
this.gain = null;
|
|
195
|
+
this.playhead = 0;
|
|
196
|
+
this.scheduled = 0;
|
|
197
|
+
this.remainder = new Uint8Array(0);
|
|
198
|
+
if (context && context.state !== "closed")
|
|
199
|
+
await context.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -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
|
+
}
|