@datalayer/agent-runtimes 1.5.43 → 1.5.45
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/dist-embed/chunks/1f30a-DxwAt2Ba.js +1 -0
- package/dist-embed/chunks/1f310-AqUM9a8q.js +1 -0
- package/dist-embed/chunks/1f324-8MHLOhTj.js +1 -0
- package/dist-embed/chunks/1f326-YSBr3aXI.js +1 -0
- package/dist-embed/chunks/1f327-Ie-nQBNO.js +1 -0
- package/dist-embed/chunks/1f33e-Cfl2WM63.js +1 -0
- package/dist-embed/chunks/1f372-3XLDlXkE.js +1 -0
- package/dist-embed/chunks/1f373-BPUJt2bp.js +1 -0
- package/dist-embed/chunks/1f39b-CSNtbAXr.js +1 -0
- package/dist-embed/chunks/1f3ab-BUCNQHz1.js +1 -0
- package/dist-embed/chunks/1f41b-Dre1z0gP.js +1 -0
- package/dist-embed/chunks/1f441-D8yah4C4.js +1 -0
- package/dist-embed/chunks/1f465-CPle6Hjq.js +1 -0
- package/dist-embed/chunks/1f4b9-aacFyckt.js +1 -0
- package/dist-embed/chunks/1f4be-DPAcAU4R.js +1 -0
- package/dist-embed/chunks/1f4c1-Ch6aJ0cj.js +1 -0
- package/dist-embed/chunks/1f4c2-Dx7BKmLO.js +1 -0
- package/dist-embed/chunks/1f4c4-CNvjvC5k.js +1 -0
- package/dist-embed/chunks/1f4d3-GTxRgjv7.js +1 -0
- package/dist-embed/chunks/1f4d6-COOZY-Q_.js +1 -0
- package/dist-embed/chunks/1f4dc-BhoW4cjD.js +1 -0
- package/dist-embed/chunks/1f4e1-DezU8ow-.js +1 -0
- package/dist-embed/chunks/1f4e2-DsStZsI-.js +1 -0
- package/dist-embed/chunks/1f4e7-BBi7c_zx.js +1 -0
- package/dist-embed/chunks/1f500-D9xRo-IT.js +1 -0
- package/dist-embed/chunks/1f501-CzKeDPcw.js +1 -0
- package/dist-embed/chunks/1f504-BYIHMoDP.js +1 -0
- package/dist-embed/chunks/1f52d-2QxCLKhU.js +1 -0
- package/dist-embed/chunks/1f552-4d0Y0kn7.js +1 -0
- package/dist-embed/chunks/1f58b-Cp8F_HBg.js +1 -0
- package/dist-embed/chunks/1f5b2-D6BUULuh.js +1 -0
- package/dist-embed/chunks/1f5c3-DjkgJLaz.js +1 -0
- package/dist-embed/chunks/1f5d1-C4Kpe4HL.js +1 -0
- package/dist-embed/chunks/1f5dc-DyLO7aB9.js +1 -0
- package/dist-embed/chunks/1f604-CEekcaYE.js +1 -0
- package/dist-embed/chunks/1f917-CyyS9fgf.js +1 -0
- package/dist-embed/chunks/1f9d1-200d-2696-D9eSB-0q.js +1 -0
- package/dist-embed/chunks/1fa79-DHhIZbWB.js +1 -0
- package/dist-embed/chunks/1fa9d-DUI9e77m.js +1 -0
- package/dist-embed/chunks/1fa9f-10ALsMaD.js +1 -0
- package/dist-embed/chunks/23f3-ASjqFsbT.js +1 -0
- package/dist-embed/chunks/2611-BnnmLm-K.js +1 -0
- package/dist-embed/chunks/2702-CfLyp35r.js +1 -0
- package/dist-embed/chunks/270f-CLwo30mk.js +1 -0
- package/dist-embed/chunks/2728-Qg6be-6p.js +1 -0
- package/dist-embed/chunks/2b1c-Uc1aRwAf.js +1 -0
- package/dist-embed/chunks/{ChatView-kCmBbnDa.js → ChatView-Dy-IgGFL.js} +1 -1
- package/dist-embed/datalayer-app-main.js +111 -106
- package/lib/apps/plugins/chat/ChatView.js +2 -2
- package/lib/chat/ChatFloating.js +1 -1
- package/lib/chat/ChatInline.js +43 -22
- package/lib/chat/base/ChatBase.js +66 -10
- package/lib/chat/messages/ChatMessageList.d.ts +7 -1
- package/lib/chat/messages/ChatMessageList.js +8 -2
- package/lib/components/agents/AgentSummary.d.ts +2 -24
- package/lib/components/agents/AgentSummary.js +3 -19
- package/lib/hooks/useChat.js +110 -144
- package/lib/portable/activity.d.ts +78 -0
- package/lib/portable/activity.js +129 -0
- package/lib/portable/fixtures.d.ts +7 -0
- package/lib/portable/fixtures.js +50 -0
- package/lib/portable/index.d.ts +4 -1
- package/lib/portable/index.js +7 -1
- package/lib/portable/lifecycle.d.ts +77 -0
- package/lib/portable/lifecycle.js +135 -0
- package/lib/portable/runtimes.d.ts +68 -0
- package/lib/portable/runtimes.js +69 -0
- package/lib/protocols/VercelAIAdapter.js +11 -0
- package/lib/specs/voices.js +62 -0
- package/lib/types/chat.d.ts +10 -0
- package/lib/voice/ComposerVoice.js +12 -3
- package/lib/voice/VoiceMenu.d.ts +2 -1
- package/lib/voice/VoiceMenu.js +2 -2
- package/lib/voice/captions.d.ts +31 -0
- package/lib/voice/captions.js +156 -0
- package/lib/voice/chatVoice.d.ts +5 -2
- package/lib/voice/chatVoice.js +10 -4
- package/lib/voice/choices.d.ts +2 -0
- package/lib/voice/choices.js +6 -0
- package/lib/voice/handsFree.d.ts +12 -0
- package/lib/voice/handsFree.js +18 -0
- package/lib/voice/index.d.ts +1 -0
- package/lib/voice/index.js +1 -0
- package/lib/voice/sayAloud.d.ts +25 -0
- package/lib/voice/sayAloud.js +34 -0
- package/lib/voice/serverHearing.d.ts +63 -2
- package/lib/voice/serverHearing.js +311 -7
- package/lib/voice/settings.d.ts +12 -0
- package/lib/voice/settings.js +9 -0
- package/package.json +1 -1
- package/scripts/voice/measure.py +77 -17
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Hearing on Datalayer's servers (VOICE.md VO-34, VO-14, §5): what was said
|
|
7
7
|
* in one press, kept to its speech by Silero VAD in the page, then sent as a
|
|
8
8
|
* WAV recording to the speech service's `POST /speech/transcriptions`, where
|
|
9
|
-
* Whisper
|
|
9
|
+
* Whisper hears it — small for a spoken turn, large-v3-turbo past one
|
|
10
|
+
* (decision 17) — in any language it knows, and finding
|
|
10
11
|
* the language itself when the person asks for that (detection, VO-14). The
|
|
11
12
|
* sound is held in the request only: the service keeps neither it nor the
|
|
12
13
|
* words (VO-60).
|
|
@@ -14,8 +15,15 @@
|
|
|
14
15
|
* Used where the application's voice says `where: server`, where it says
|
|
15
16
|
* `auto` and no model in the browser hears the language, and where the
|
|
16
17
|
* person asked for their language to be detected — never when they chose to
|
|
17
|
-
* keep speech on the device.
|
|
18
|
-
*
|
|
18
|
+
* keep speech on the device.
|
|
19
|
+
*
|
|
20
|
+
* While the person speaks, the words come too (VO-11): the first look at
|
|
21
|
+
* what was heard so far opens `WS /speech/transcriptions/stream` (the token
|
|
22
|
+
* in its first message, VO-37), the sound is sent as it is captured, 16-bit
|
|
23
|
+
* PCM at 16 kHz, and the service's partial words are what was heard so far,
|
|
24
|
+
* greyed in the composer until final. At the end of the press the stream is
|
|
25
|
+
* told so and its final words are the transcript. A press heard without a
|
|
26
|
+
* look before it (a short one) is sent whole as a recording instead.
|
|
19
27
|
*
|
|
20
28
|
* @module voice/serverHearing
|
|
21
29
|
*/
|
|
@@ -23,10 +31,19 @@ import { transcriberFor } from '../specs/voices';
|
|
|
23
31
|
import { HEARING_RATE } from './capture';
|
|
24
32
|
/** Where the speech service hears a recording, on ai-agents' host. */
|
|
25
33
|
export const TRANSCRIPTIONS_PATH = '/api/ai-agents/v1/speech/transcriptions';
|
|
34
|
+
/** Where the speech service hears speech as it is said. */
|
|
35
|
+
export const TRANSCRIPTION_STREAM_PATH = '/api/ai-agents/v1/speech/transcriptions/stream';
|
|
36
|
+
/** A stream no look or hearing has fed for this long is closed: its press was cancelled. */
|
|
37
|
+
export const STREAM_IDLE_MS = 3000;
|
|
38
|
+
/** How long the final words of a stream are waited for. */
|
|
39
|
+
export const STREAM_FINAL_MS = 60_000;
|
|
26
40
|
/** The model the speech service hears with (agentspecs' catalogue). */
|
|
27
41
|
export const SERVER_HEARING_MODEL = 'whisper-large-v3-turbo';
|
|
28
42
|
/** The shortest speech worth hearing, in samples: a word said alone. */
|
|
29
43
|
const SHORTEST = HEARING_RATE * 0.2;
|
|
44
|
+
/** The speech service refused a press, in its own sentence (a limit, a visitor's day). */
|
|
45
|
+
export class ServerRefused extends Error {
|
|
46
|
+
}
|
|
30
47
|
/**
|
|
31
48
|
* Where a chat's voice hears (§5): in the browser unless the speech service
|
|
32
49
|
* is named and the person asked for detection, the application says
|
|
@@ -72,6 +89,162 @@ export function wavOf(samples, rate = HEARING_RATE) {
|
|
|
72
89
|
}
|
|
73
90
|
return bytes;
|
|
74
91
|
}
|
|
92
|
+
/** 16 kHz samples in [-1, 1] as little-endian 16-bit PCM: what a `pcm16` stream takes. */
|
|
93
|
+
export function pcm16Of(samples) {
|
|
94
|
+
const bytes = new ArrayBuffer(samples.length * 2);
|
|
95
|
+
const view = new DataView(bytes);
|
|
96
|
+
for (let i = 0; i < samples.length; i++) {
|
|
97
|
+
const sample = Math.max(-1, Math.min(1, samples[i]));
|
|
98
|
+
view.setInt16(i * 2, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
|
99
|
+
}
|
|
100
|
+
return bytes;
|
|
101
|
+
}
|
|
102
|
+
/** The ws(s) address of the stream on ai-agents' origin. */
|
|
103
|
+
export function streamUrlOf(speechUrl) {
|
|
104
|
+
return `${speechUrl.replace(/\/+$/, '').replace(/^http/, 'ws')}${TRANSCRIPTION_STREAM_PATH}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* One press heard as it is said: the sound sent as it comes, the service's
|
|
108
|
+
* partial words kept, its final words awaited at the end.
|
|
109
|
+
*/
|
|
110
|
+
class PressStream {
|
|
111
|
+
/** Samples of the press sent so far. */
|
|
112
|
+
sent = 0;
|
|
113
|
+
/** The newest partial words. */
|
|
114
|
+
partial = '';
|
|
115
|
+
/** Why the service refused, in its sentence. */
|
|
116
|
+
refused;
|
|
117
|
+
socket;
|
|
118
|
+
ready = false;
|
|
119
|
+
queued = [];
|
|
120
|
+
final;
|
|
121
|
+
waiting;
|
|
122
|
+
closed = false;
|
|
123
|
+
idle;
|
|
124
|
+
constructor(open, speechUrl, first) {
|
|
125
|
+
this.socket = open(streamUrlOf(speechUrl));
|
|
126
|
+
this.socket.binaryType = 'arraybuffer';
|
|
127
|
+
this.socket.onopen = () => {
|
|
128
|
+
this.socket.send(JSON.stringify({
|
|
129
|
+
token: first.token ?? '',
|
|
130
|
+
language: first.language ?? '',
|
|
131
|
+
format: 'pcm16',
|
|
132
|
+
}));
|
|
133
|
+
};
|
|
134
|
+
this.socket.onmessage = event => {
|
|
135
|
+
let said;
|
|
136
|
+
try {
|
|
137
|
+
said = JSON.parse(String(event.data));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (said.type === 'ready') {
|
|
143
|
+
this.ready = true;
|
|
144
|
+
for (const chunk of this.queued.splice(0)) {
|
|
145
|
+
this.socket.send(chunk);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (said.type === 'partial' && typeof said.text === 'string') {
|
|
149
|
+
this.partial = said.text.trim();
|
|
150
|
+
}
|
|
151
|
+
else if (said.type === 'final') {
|
|
152
|
+
this.final = {
|
|
153
|
+
text: typeof said.text === 'string' ? said.text.trim() : '',
|
|
154
|
+
language: typeof said.language === 'string' ? said.language : '',
|
|
155
|
+
};
|
|
156
|
+
this.settle();
|
|
157
|
+
}
|
|
158
|
+
else if (said.type === 'refused') {
|
|
159
|
+
this.refused =
|
|
160
|
+
typeof said.detail === 'string' && said.detail
|
|
161
|
+
? said.detail
|
|
162
|
+
: "What you said could not be heard on Datalayer's servers.";
|
|
163
|
+
this.settle();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
this.socket.onclose = () => {
|
|
167
|
+
this.closed = true;
|
|
168
|
+
this.settle();
|
|
169
|
+
};
|
|
170
|
+
this.socket.onerror = () => undefined;
|
|
171
|
+
this.touch();
|
|
172
|
+
}
|
|
173
|
+
/** Send what the press added since the last call. */
|
|
174
|
+
feed(audio) {
|
|
175
|
+
this.touch();
|
|
176
|
+
if (audio.length <= this.sent || this.closed) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const chunk = pcm16Of(audio.subarray(this.sent));
|
|
180
|
+
this.sent = audio.length;
|
|
181
|
+
if (this.ready) {
|
|
182
|
+
this.socket.send(chunk);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
this.queued.push(chunk);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** The end of the press: the final words, or `undefined` when the stream ended without them. */
|
|
189
|
+
finish(audio) {
|
|
190
|
+
this.feed(audio);
|
|
191
|
+
if (this.final || this.closed || this.refused) {
|
|
192
|
+
return Promise.resolve(this.final);
|
|
193
|
+
}
|
|
194
|
+
return new Promise(resolve => {
|
|
195
|
+
const timer = setTimeout(() => {
|
|
196
|
+
this.waiting = undefined;
|
|
197
|
+
this.close();
|
|
198
|
+
resolve(undefined);
|
|
199
|
+
}, STREAM_FINAL_MS);
|
|
200
|
+
this.waiting = final => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
resolve(final);
|
|
203
|
+
};
|
|
204
|
+
const end = JSON.stringify({ type: 'end' });
|
|
205
|
+
if (this.ready) {
|
|
206
|
+
this.socket.send(end);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Said once the service is ready, after the sound queued before it.
|
|
210
|
+
this.queued.push(end);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
close() {
|
|
215
|
+
if (this.idle) {
|
|
216
|
+
clearTimeout(this.idle);
|
|
217
|
+
}
|
|
218
|
+
this.closed = true;
|
|
219
|
+
try {
|
|
220
|
+
this.socket.close(1000);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Already closed.
|
|
224
|
+
}
|
|
225
|
+
this.settle();
|
|
226
|
+
}
|
|
227
|
+
touch() {
|
|
228
|
+
if (this.idle) {
|
|
229
|
+
clearTimeout(this.idle);
|
|
230
|
+
}
|
|
231
|
+
this.idle = setTimeout(() => {
|
|
232
|
+
if (!this.waiting) {
|
|
233
|
+
this.close();
|
|
234
|
+
}
|
|
235
|
+
}, STREAM_IDLE_MS);
|
|
236
|
+
}
|
|
237
|
+
settle() {
|
|
238
|
+
const waiting = this.waiting;
|
|
239
|
+
this.waiting = undefined;
|
|
240
|
+
waiting?.(this.final);
|
|
241
|
+
if (this.final || this.refused) {
|
|
242
|
+
if (this.idle) {
|
|
243
|
+
clearTimeout(this.idle);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
75
248
|
/**
|
|
76
249
|
* Hears on Datalayer's servers: the browser's VAD finds the speech and when
|
|
77
250
|
* it stopped; Whisper, on the speech service, writes it.
|
|
@@ -88,17 +261,75 @@ export class ServerHearing {
|
|
|
88
261
|
ready(_language, progress) {
|
|
89
262
|
return this.finder.readyToFindSpeech(progress);
|
|
90
263
|
}
|
|
91
|
-
/**
|
|
92
|
-
|
|
264
|
+
/** The press being heard as it is said, while one is. */
|
|
265
|
+
press;
|
|
266
|
+
/** The press's stream, opened anew when a new press began (its sound is shorter). */
|
|
267
|
+
streamFor(audio, language) {
|
|
268
|
+
const open = this.options.socket ??
|
|
269
|
+
(typeof WebSocket === 'undefined'
|
|
270
|
+
? undefined
|
|
271
|
+
: (url) => new WebSocket(url));
|
|
272
|
+
if (!open) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
if (this.press && audio.length < this.press.sent) {
|
|
276
|
+
this.press.close();
|
|
277
|
+
this.press = undefined;
|
|
278
|
+
}
|
|
279
|
+
if (!this.press) {
|
|
280
|
+
this.press = new PressStream(open, this.options.speechUrl, {
|
|
281
|
+
token: this.options.token,
|
|
282
|
+
language: this.options.detect ? '' : language,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return this.press;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Whether speech was found and how long it has been silent, and the words
|
|
289
|
+
* the speech service heard so far: the sound goes to its stream as it comes.
|
|
290
|
+
*/
|
|
291
|
+
async hearSoFar(audio, language = '') {
|
|
93
292
|
const { speech, silentMs } = await this.finder.findSpeech(audio);
|
|
94
|
-
|
|
293
|
+
const spoke = speech.length >= SHORTEST;
|
|
294
|
+
const press = this.streamFor(audio, language);
|
|
295
|
+
press?.feed(audio);
|
|
296
|
+
return { text: spoke ? (press?.partial ?? '') : '', spoke, silentMs };
|
|
95
297
|
}
|
|
96
298
|
async hear(audio, language) {
|
|
97
299
|
const started = performance.now();
|
|
300
|
+
const press = this.press && audio.length >= this.press.sent ? this.press : undefined;
|
|
301
|
+
if (this.press && !press) {
|
|
302
|
+
this.press.close();
|
|
303
|
+
}
|
|
304
|
+
this.press = undefined;
|
|
98
305
|
const { speech } = await this.finder.findSpeech(audio);
|
|
99
306
|
if (speech.length < SHORTEST) {
|
|
307
|
+
press?.close();
|
|
100
308
|
return undefined;
|
|
101
309
|
}
|
|
310
|
+
if (press) {
|
|
311
|
+
const final = await press.finish(audio);
|
|
312
|
+
press.close();
|
|
313
|
+
if (press.refused) {
|
|
314
|
+
throw new ServerRefused(press.refused);
|
|
315
|
+
}
|
|
316
|
+
if (!final) {
|
|
317
|
+
throw new Error("What you said could not be heard on Datalayer's servers: the stream ended before its words.");
|
|
318
|
+
}
|
|
319
|
+
return final.text
|
|
320
|
+
? {
|
|
321
|
+
text: final.text,
|
|
322
|
+
metadata: {
|
|
323
|
+
input: 'voice',
|
|
324
|
+
language: final.language || language,
|
|
325
|
+
engine: SERVER_HEARING_MODEL,
|
|
326
|
+
where: 'server',
|
|
327
|
+
},
|
|
328
|
+
seconds: audio.length / HEARING_RATE,
|
|
329
|
+
ms: Math.round(performance.now() - started),
|
|
330
|
+
}
|
|
331
|
+
: undefined;
|
|
332
|
+
}
|
|
102
333
|
const { speechUrl, token, detect, fetcher = fetch } = this.options;
|
|
103
334
|
const query = detect ? '' : `?language=${encodeURIComponent(language)}`;
|
|
104
335
|
const answered = await fetcher(`${speechUrl.replace(/\/+$/, '')}${TRANSCRIPTIONS_PATH}${query}`, {
|
|
@@ -111,7 +342,7 @@ export class ServerHearing {
|
|
|
111
342
|
});
|
|
112
343
|
const body = (await answered.json().catch(() => ({})));
|
|
113
344
|
if (!answered.ok) {
|
|
114
|
-
throw new
|
|
345
|
+
throw new ServerRefused(typeof body.detail === 'string' && body.detail
|
|
115
346
|
? body.detail
|
|
116
347
|
: `What you said could not be heard on Datalayer's servers (${answered.status}).`);
|
|
117
348
|
}
|
|
@@ -134,3 +365,76 @@ export class ServerHearing {
|
|
|
134
365
|
};
|
|
135
366
|
}
|
|
136
367
|
}
|
|
368
|
+
/** Said when the device's model did not load and the server will hear instead. */
|
|
369
|
+
export const MOVED_TO_SERVER = "This device could not load its model: what you say will be heard on Datalayer's servers instead. Press the microphone again to agree.";
|
|
370
|
+
/**
|
|
371
|
+
* `where: auto` (§5): each press heard on the device when a model there
|
|
372
|
+
* hears the language and loads, on the speech service otherwise.
|
|
373
|
+
*
|
|
374
|
+
* The device's model failing to load moves the hearing to the server, and
|
|
375
|
+
* the press is refused in a sentence so that the server's own consent is
|
|
376
|
+
* asked at the next one. The server refusing a press — a limit, a visitor's
|
|
377
|
+
* day spent — moves it back to the device when a model there hears the
|
|
378
|
+
* language, and the press is heard there: the sound then stays in the page.
|
|
379
|
+
*/
|
|
380
|
+
export class AutoHearing {
|
|
381
|
+
device;
|
|
382
|
+
server;
|
|
383
|
+
hearsHere;
|
|
384
|
+
place;
|
|
385
|
+
/** The device's model did not load: it is not tried again on this page. */
|
|
386
|
+
deviceBroken = false;
|
|
387
|
+
constructor(device, server, language, hearsHere = language => Boolean(transcriberFor(language))) {
|
|
388
|
+
this.device = device;
|
|
389
|
+
this.server = server;
|
|
390
|
+
this.hearsHere = hearsHere;
|
|
391
|
+
this.place = hearsHere(language) ? 'device' : 'server';
|
|
392
|
+
}
|
|
393
|
+
get where() {
|
|
394
|
+
return this.place;
|
|
395
|
+
}
|
|
396
|
+
get current() {
|
|
397
|
+
return this.place === 'device' ? this.device : this.server;
|
|
398
|
+
}
|
|
399
|
+
async ready(language, progress) {
|
|
400
|
+
if (this.place === 'server' &&
|
|
401
|
+
!this.deviceBroken &&
|
|
402
|
+
this.hearsHere(language)) {
|
|
403
|
+
// A language the device hears, chosen after a server-only one.
|
|
404
|
+
this.place = 'device';
|
|
405
|
+
}
|
|
406
|
+
if (this.place === 'device') {
|
|
407
|
+
try {
|
|
408
|
+
await this.device.ready(language, progress);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
this.place = 'server';
|
|
413
|
+
this.deviceBroken = true;
|
|
414
|
+
throw new Error(MOVED_TO_SERVER);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
await this.server.ready(language, progress);
|
|
418
|
+
}
|
|
419
|
+
hearSoFar(audio, language) {
|
|
420
|
+
return this.current.hearSoFar(audio, language);
|
|
421
|
+
}
|
|
422
|
+
async hear(audio, language) {
|
|
423
|
+
if (this.place === 'device') {
|
|
424
|
+
return this.device.hear(audio, language);
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
return await this.server.hear(audio, language);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
if (!(error instanceof ServerRefused) ||
|
|
431
|
+
this.deviceBroken ||
|
|
432
|
+
!this.hearsHere(language)) {
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
this.place = 'device';
|
|
436
|
+
await this.device.ready(language);
|
|
437
|
+
return this.device.hear(audio, language);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
package/lib/voice/settings.d.ts
CHANGED
|
@@ -33,6 +33,16 @@ export declare const VOLUME_RANGE: {
|
|
|
33
33
|
readonly max: 1;
|
|
34
34
|
readonly default: 1;
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* How much longer than usual the person pauses before they are done
|
|
38
|
+
* (VOICE.md VO-69): the silence that stops a microphone clicked on, and the
|
|
39
|
+
* one that ends a hands-free turn, times this.
|
|
40
|
+
*/
|
|
41
|
+
export declare const PAUSE_RANGE: {
|
|
42
|
+
readonly min: 1;
|
|
43
|
+
readonly max: 4;
|
|
44
|
+
readonly default: 1;
|
|
45
|
+
};
|
|
36
46
|
/**
|
|
37
47
|
* What a person chose for themselves. Every field is optional: an absent one
|
|
38
48
|
* means "whatever the application says".
|
|
@@ -62,6 +72,8 @@ export interface VoiceSettings {
|
|
|
62
72
|
* heard on Datalayer's servers, which detect it, rather than in the browser.
|
|
63
73
|
*/
|
|
64
74
|
detectLanguage?: boolean;
|
|
75
|
+
/** How much longer they pause before they are done (VO-69): 1 is usual. */
|
|
76
|
+
pause?: number;
|
|
65
77
|
}
|
|
66
78
|
/** The settings as they can be used: numbers in range, nothing else trusted. */
|
|
67
79
|
export declare function saneVoiceSettings(settings: VoiceSettings | null | undefined): VoiceSettings;
|
package/lib/voice/settings.js
CHANGED
|
@@ -7,6 +7,12 @@ export const VOICE_SETTINGS_KEY = 'datalayer-voice-settings';
|
|
|
7
7
|
/** How fast and how loud, as multipliers of the voice's own. */
|
|
8
8
|
export const RATE_RANGE = { min: 0.5, max: 2, default: 1 };
|
|
9
9
|
export const VOLUME_RANGE = { min: 0, max: 1, default: 1 };
|
|
10
|
+
/**
|
|
11
|
+
* How much longer than usual the person pauses before they are done
|
|
12
|
+
* (VOICE.md VO-69): the silence that stops a microphone clicked on, and the
|
|
13
|
+
* one that ends a hands-free turn, times this.
|
|
14
|
+
*/
|
|
15
|
+
export const PAUSE_RANGE = { min: 1, max: 4, default: 1 };
|
|
10
16
|
function clamp(value, { min, max }) {
|
|
11
17
|
return Math.min(max, Math.max(min, value));
|
|
12
18
|
}
|
|
@@ -43,6 +49,9 @@ export function saneVoiceSettings(settings) {
|
|
|
43
49
|
if (typeof kept.detectLanguage === 'boolean') {
|
|
44
50
|
sane.detectLanguage = kept.detectLanguage;
|
|
45
51
|
}
|
|
52
|
+
if (typeof kept.pause === 'number' && Number.isFinite(kept.pause)) {
|
|
53
|
+
sane.pause = clamp(kept.pause, PAUSE_RANGE);
|
|
54
|
+
}
|
|
46
55
|
return sane;
|
|
47
56
|
}
|
|
48
57
|
/** What was kept, or nothing. Storage may be refused, or hold nonsense. */
|
package/package.json
CHANGED
package/scripts/voice/measure.py
CHANGED
|
@@ -92,6 +92,29 @@ def transcribe(
|
|
|
92
92
|
return json.loads(done.stdout.strip().splitlines()[-1])
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
#: Spoken turns timed on the server (VO-47, decision 18): a 3 s fixture and the
|
|
96
|
+
#: e2e suite's 9 s question, each heard ``TURN_RUNS`` times told its language.
|
|
97
|
+
TURNS = {
|
|
98
|
+
"3s": VOICE_TESTS / "fixtures" / "en-6930-75918-0013.flac",
|
|
99
|
+
"9s": ROOT / "e2e" / "fixtures" / "weather-in-paris.wav",
|
|
100
|
+
}
|
|
101
|
+
TURN_RUNS = 5
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def server_transcriber(model: Dict[str, Any], folder: Path, threads: int) -> Any:
|
|
105
|
+
"""The speech service's own engine for a model, as the image runs it (``datalayer_ai_agents``)."""
|
|
106
|
+
from datalayer_ai_agents.speech.transcription import ( # the speech image's own
|
|
107
|
+
FasterWhisperTranscriber,
|
|
108
|
+
MoonshineTranscriber,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if model["engine"] == "faster-whisper":
|
|
112
|
+
return FasterWhisperTranscriber(folder, model["id"], threads=threads)
|
|
113
|
+
if model["engine"] == "onnxruntime":
|
|
114
|
+
return MoonshineTranscriber(folder, model["id"], threads=threads)
|
|
115
|
+
raise SystemExit(f"{model['id']}: no server engine {model['engine']!r}")
|
|
116
|
+
|
|
117
|
+
|
|
95
118
|
def measure_server(
|
|
96
119
|
store: Path,
|
|
97
120
|
voices: Any,
|
|
@@ -99,37 +122,34 @@ def measure_server(
|
|
|
99
122
|
wavs: Dict[str, Path],
|
|
100
123
|
measured: Path,
|
|
101
124
|
threads: int,
|
|
125
|
+
only: List[str],
|
|
102
126
|
) -> None:
|
|
103
127
|
"""Each server speech-to-text model on the fixtures, as the speech service runs it (VO-47).
|
|
104
128
|
|
|
105
|
-
faster-whisper
|
|
106
|
-
|
|
129
|
+
The service's own engines (faster-whisper 8-bit, Moonshine through
|
|
130
|
+
onnxruntime) on the CPU with ``threads`` threads, told the language, one
|
|
131
|
+
clip at a time: what it heard and how long it took; then a 3 s and a 9 s
|
|
132
|
+
English turn, each heard ``TURN_RUNS`` times (decision 18).
|
|
107
133
|
"""
|
|
108
|
-
from faster_whisper import WhisperModel # the speech image's own
|
|
109
|
-
|
|
110
134
|
for model in voices.SPEECH_MODEL_CATALOGUE.values():
|
|
111
135
|
if model["task"] != "stt" or model["where"] != ["server"]:
|
|
112
136
|
continue
|
|
137
|
+
if only and model["id"] not in only:
|
|
138
|
+
continue
|
|
113
139
|
started = time.monotonic()
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
device="cpu",
|
|
117
|
-
compute_type="int8",
|
|
118
|
-
cpu_threads=threads,
|
|
140
|
+
engine = server_transcriber(
|
|
141
|
+
model, store / model["provider"] / model["id"], threads
|
|
119
142
|
)
|
|
120
143
|
load_ms = round((time.monotonic() - started) * 1000)
|
|
121
144
|
by_language = {}
|
|
122
|
-
for language in ("en", "fr"):
|
|
145
|
+
for language in [code for code in ("en", "fr") if code in model["languages"]]:
|
|
123
146
|
clips = [clip for clip in fixtures if clip["language"] == language]
|
|
124
147
|
print(f"{model['id']} on {len(clips)} {language} clips", flush=True)
|
|
125
148
|
results = []
|
|
126
149
|
for clip in clips:
|
|
127
150
|
samples, _ = sf.read(wavs[clip["id"]], dtype="float32")
|
|
128
151
|
started = time.monotonic()
|
|
129
|
-
|
|
130
|
-
samples, language=language, vad_filter=True
|
|
131
|
-
)
|
|
132
|
-
text = " ".join(part.text.strip() for part in parts).strip()
|
|
152
|
+
text = engine.transcribe(samples, language=language).text
|
|
133
153
|
results.append(
|
|
134
154
|
{
|
|
135
155
|
"id": clip["id"],
|
|
@@ -139,12 +159,40 @@ def measure_server(
|
|
|
139
159
|
}
|
|
140
160
|
)
|
|
141
161
|
by_language[language] = {"load_ms": load_ms, "clips": results}
|
|
162
|
+
turns = {}
|
|
163
|
+
if "en" in model["languages"]:
|
|
164
|
+
for name, path in TURNS.items():
|
|
165
|
+
samples, rate = sf.read(path, dtype="float32")
|
|
166
|
+
samples = resample(samples, rate)
|
|
167
|
+
engine.transcribe(samples, language="en") # warm
|
|
168
|
+
took = []
|
|
169
|
+
for _ in range(TURN_RUNS):
|
|
170
|
+
started = time.monotonic()
|
|
171
|
+
heard = engine.transcribe(samples, language="en").text
|
|
172
|
+
took.append(round((time.monotonic() - started) * 1000))
|
|
173
|
+
turns[name] = {
|
|
174
|
+
"file": path.name,
|
|
175
|
+
"audio_s": round(len(samples) / 16000, 2),
|
|
176
|
+
"ms": took,
|
|
177
|
+
"median_ms": sorted(took)[len(took) // 2],
|
|
178
|
+
"heard": heard,
|
|
179
|
+
}
|
|
180
|
+
print(
|
|
181
|
+
f"{model['id']} {name} turn: {turns[name]['median_ms']} ms",
|
|
182
|
+
flush=True,
|
|
183
|
+
)
|
|
184
|
+
runtime = (
|
|
185
|
+
f"faster-whisper, int8 on the CPU, {threads} threads"
|
|
186
|
+
if model["engine"] == "faster-whisper"
|
|
187
|
+
else f"onnxruntime {model['dtype']} on the CPU, greedy, {threads} threads"
|
|
188
|
+
)
|
|
142
189
|
(measured / f"stt-{model['id']}.json").write_text(
|
|
143
190
|
json.dumps(
|
|
144
191
|
{
|
|
145
192
|
"model": model["id"],
|
|
146
|
-
"runtime":
|
|
193
|
+
"runtime": runtime,
|
|
147
194
|
"languages": by_language,
|
|
195
|
+
**({"turns": turns} if turns else {}),
|
|
148
196
|
},
|
|
149
197
|
ensure_ascii=False,
|
|
150
198
|
indent=2,
|
|
@@ -165,7 +213,13 @@ def main() -> int:
|
|
|
165
213
|
parser.add_argument(
|
|
166
214
|
"--server",
|
|
167
215
|
action="store_true",
|
|
168
|
-
help="Measure the server's speech-to-text models instead, with
|
|
216
|
+
help="Measure the server's speech-to-text models instead, with the speech service's engines (in the speech image)",
|
|
217
|
+
)
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
"--model",
|
|
220
|
+
action="append",
|
|
221
|
+
default=[],
|
|
222
|
+
help="With --server, only this model (repeatable)",
|
|
169
223
|
)
|
|
170
224
|
parser.add_argument(
|
|
171
225
|
"--threads", type=int, default=4, help="CPU threads for --server"
|
|
@@ -195,7 +249,13 @@ def main() -> int:
|
|
|
195
249
|
wavs[clip["id"]] = wav
|
|
196
250
|
if args.server:
|
|
197
251
|
measure_server(
|
|
198
|
-
args.store,
|
|
252
|
+
args.store,
|
|
253
|
+
voices,
|
|
254
|
+
fixtures,
|
|
255
|
+
wavs,
|
|
256
|
+
measured,
|
|
257
|
+
args.threads,
|
|
258
|
+
args.model,
|
|
199
259
|
)
|
|
200
260
|
return 0
|
|
201
261
|
# --- speech to text, on the device's models ---------------------------------
|